Prechádzať zdrojové kódy

regen certsa and some simplification

Dr-Swopt 4 dní pred
rodič
commit
f38b9ac398

+ 0 - 28
cert/127.0.0.1+1-key.pem

@@ -1,28 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHXaPAKYkdty0k
-Hge1UmjwkR5MJYYLsLtwUsKhzVxoXUJcj2EeEm/AyHDZ1itSMXnK3ro8Dnjd0xv+
-efoHeJIYZ7UzrTWf69SK9C5jHdBPM+XQB5MsJ7P29bVGCjJ4ep4ggY7je/SPSnV2
-SlnB3j7jjZHqfvgU9rZUYgvdDCc3h8Xj1p8XjaJFzvJTm3B62HZwD67jA0PwCfUz
-EVEQJRJFTwLn3XRm3bpMRxPGna6MaOXveX9zXn4BGtrGtX6IxoD0zHHKGy5RdvJn
-DMy8qnX7/Ho1qRXl/Nmob8S0rI1PBvodGr/KIluHaA9o4UO3dwRgfCaPenh4Ko+b
-QvARvaUtAgMBAAECggEAQJBqv4i0BxOTYub2yBnwMjhM/4wHZTHPAglLTusayhGp
-tCAa64o89snzAhkB3pR3ROPsnBZzviLoJfmKp3C8n3q2jA3EGA5fvsBlZWP6WiwM
-eNp7JwmUlp3sHsqenbXYD97lT3aNNPqAH9bkSoyXAUqPvslvvhpH4fv+q4+MA8c5
-g8x9AVnQY2kVdRcr0zLgfGQJxPYgL+nWUhn/pzRO0sdm/zJyvpVZtrJdIH85VdoD
-IUR86e4jlsxsKJ7bl2rSzx393A1tLmnKanJTT8wl/PtZ527tSCqcuxVkQM84Tvgj
-8nXn8HgtYhSAMoOeZoeFMUm8gD2R2lFbRB7zULhxAQKBgQD6/l2+G+GOpUGYPzac
-dzJC1iIMdEnEEyOywf8l8nmWwNP39Yp0nddleu0mu11njvh0nOspT+m7ZUjqFkhs
-q3139nNyLGIeg4WHDn+FbIW4RCfwjPRcuknrRbDxmsCmBpZSjrLXYA0VvdVlii0u
-EJqJPpNoXZ1ZSxn7GTqW43x66QKBgQDLV6Y4uQI5rfJ3ubrLNGUF99v/iaXZVkPh
-asuDZNXFWMo+TG7EI6HHylq/j34bswBmknCjWmvQ7ULmTaz+rstGoVE4Cm6+/gJ9
-JXL2bM8UpWI0hGn6zERiyTJu3XlZ2AS9Cw3yLg/lEpi3GgBmlbXWmXqSpuL7kib+
-+Mpn2hDlpQKBgQDlFVUqNvhf4aVE+C04EfLl3dul0l2hgHaMqVPfprgjSEwvfQSp
-+4alMNVTDJ/r7SoIBVD9m9qRF5i9Tyk7Rip2W5JzGt9TSmeNJUZu2OYTkOGDRKOk
-HsNo4WrmmYBMCKcbIvNIcHqA5Yrn6n3iFXV23o5cK1V6Mnm8HQLExUzQQQKBgC9R
-x1G95AGuNBWeeBSfrb60zlJqItkv1P4ZDyEVjxWssuvKd6BXNme69GFNsCgcAMTd
-4S5ydVKaVA4qF07xOEbIdZEYBGuXytZ6p4UnDw2b6v2TruH5NRTUA1N/YKUCux+O
-+gDYrUQ+jqFVgLBeuIEnGDoWcg3fFgoRtXBzc6ktAoGBANeTLJIa8C3x5DZ1oOca
-8B4+bjyrqQDTHF3V+eebHa9fY+55fyX3gaPpdGyCqtbfBQ6Bc+lD/VAZjhLLyPf/
-JTDIDf2cYrdp4qMGCa/IpYdHfW3/gWgd0dXc71PtaXjcUhB0sfC8BbUAI9yxcY2k
-e5dUYISNi3Z62lsd+ra9KeWa
------END PRIVATE KEY-----

+ 0 - 25
cert/127.0.0.1+1.pem

@@ -1,25 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIENDCCApygAwIBAgIQdxblIyL+7/IkNzrrKgw+FzANBgkqhkiG9w0BAQsFADB5
-MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExJzAlBgNVBAsMHlNPREMx
-NlxlbnpvQFNPUEMtMjNEMTAxIChFbnpvKTEuMCwGA1UEAwwlbWtjZXJ0IFNPREMx
-NlxlbnpvQFNPUEMtMjNEMTAxIChFbnpvKTAeFw0yNjA0MjEwMzAwMzhaFw0yODA3
-MjEwMzAwMzhaMFIxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZp
-Y2F0ZTEnMCUGA1UECwweU09EQzE2XGVuem9AU09QQy0yM0QxMDEgKEVuem8pMIIB
-IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx12jwCmJHbctJB4HtVJo8JEe
-TCWGC7C7cFLCoc1caF1CXI9hHhJvwMhw2dYrUjF5yt66PA543dMb/nn6B3iSGGe1
-M601n+vUivQuYx3QTzPl0AeTLCez9vW1RgoyeHqeIIGO43v0j0p1dkpZwd4+442R
-6n74FPa2VGIL3QwnN4fF49afF42iRc7yU5tweth2cA+u4wND8An1MxFRECUSRU8C
-5910Zt26TEcTxp2ujGjl73l/c15+ARraxrV+iMaA9MxxyhsuUXbyZwzMvKp1+/x6
-NakV5fzZqG/EtKyNTwb6HRq/yiJbh2gPaOFDt3cEYHwmj3p4eCqPm0LwEb2lLQID
-AQABo18wXTAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYD
-VR0jBBgwFoAUtrkpVhXxR16EF3vN5zAF08sezWQwFQYDVR0RBA4wDIcEfwAAAYcE
-wKhkZDANBgkqhkiG9w0BAQsFAAOCAYEAOYQXLFUi+MQ/DA5IrrfDWqxTCXNs46TQ
-6vDD3dZyM1ZbwOV7QRGc9SBkfN9GqiLOcFxJw3na6Jgv4WNabpyd0UTBJ/BVAyCw
-1NgpNJaNDrWdNGLDaff+PyVxzMyomkm6B3nvBROYAQKyZKV30A3kOQdKZFGKV+A7
-vPjOfdLeZJdQE/XSOvwWfrp0WcnH5pmLU2845QKly6ViKykOQmg5Q8/vXF88wlmh
-VuY6REPtfXYy5OiShzn0gCofwH8ebET+UKQbDe5btNexJAVYDWds3/ncg0u9VcK5
-TpZ9s491qDICygdGZoV37i45IlqPNZgZfjsFEMvjmVO+5TvvJ+jW5dRTcxIC7Vkr
-Ni8dig+7qI6pIz0/LiTi95ySuPtcBCvIJJjhQpq44LAQmOE3LKaFpzUPWZE8M9C+
-bmfo2ojztDrAh/98ailUfUb7+lzhbc5SUuJpyN8Nm9CnNP+SBbyLzjL8229PgFXG
-CrI5n1eMmFoAPHZGZUolFHqzT9hnogCe
------END CERTIFICATE-----

+ 28 - 0
cert/localhost+1-key.pem

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDaV41hbIbC6hDT
+oaYvPKJ6OoAbHFWr5Mi+mBEURwu8diJHW/9hbpf5XSXwbL1fPEIvWR+8YwVcaNOY
+FYwWDmSgGTeI/QQVxTR2XhBdEoF9ox5xSqyKmhJdtxDiT+JibhZev9hZ4iV6Ej1F
+vr/3+Mu3GtaJXcFen/cVt7xsbL5rml4/Bja2WarK6uiZfaBzklg7Ckb4Q8ShNB8Z
+Mu4RbnreRyx4dzAAxpuEWZLHDAZBM+H+rGnKI2PMBsPYwdvHRs76u9OKHqMAPMdd
+e/22YvqhFDDnirk61vW2ESmhtbWZsuWk5+n+lq3XSMkRpHgenxNF9TxxhbSrL07R
+txxh15bVAgMBAAECggEBAMds3mpUqMXQ6muSIurUNAb19dpNSAbH4X8L/9WIirSp
+JegNpDWGwPJ3XNa7S0B4Fm+OtMjpnJTp/hHT5G6k2M3OGoZZquiDhcZzZfjMlU9+
+tKh/rxatYQcN1TMQCdMjf/UsvtxiDR7xF9vjPQ2txcvJjJhM9PiLnS/N8SELNeWq
+mlPK+jo5z8MBjNwCiSrZ6Hy4NifIKqrGdkSmnZHpZFmFb+1ie+enolo10rTaW2xR
+WwvX81Bt68xgc81NiQussH8rfDR0Dd+emvh4Ii7tVfhGZKQjEyDkFOUKsjWx7kZr
+jXBLI2RIwMfXp7kxmn/NgfAzlxkz0W9g1hqeLXxicyUCgYEA8l2i8rgTCwDftv4R
+ZKz1UOntDGYL+mg0GYWcv2NVkt7d7STho05byIEYnpS7mbtb9FoHAVnCyida/o4z
+MZM+zOtPhAPG+Fy7G3iVVVBl3BtBOrbL6Z2QjlCKEn4LmVhTO+HWSW3eSSO1CU0f
+797VAp9DYZCCQ7B5dU1L2MUlFzMCgYEA5p/xsoHNRtNkK+QamUjbMHBnZ5rirVRT
+57Yy2JmvR11VQHXpYFRmYF8nxMcWiZFBM9COIQ3+p2D4L7A+S5jbI+k7adBjb0d9
+rKr8Og33qIaITsNi+u3Y82cnk8//nFH1o7/kn+nXmJRsdfPArVBRnMduReVpBxmN
+iQgi6zFeedcCgYEA4w09nk076c8Dxhb2jG5L02WSoU7oYcpFJLO7SMDyZglLtuIa
+UDcUXR4zxjxoE3kmiB/e+DDy/xcnc9obs5HR/39imrY/LGUTFIU+wRH0muMdlLez
+CESILArfjrtuelX4g9zqNxgqajJ9Yx1RkhIbU72IDlqm7mrhHjcvmv/142kCgYEA
+nlSWeafVh1dfgSaEAFJdcP7qbt2N29N2GzEh7URtaoAwJCYPR7wJ4QXS5qyL03wu
+mGUI/rZ96umO2iaUThAt+pSH3phbe61IIX/t0+l86m0aLYDEdmNOO6TJLhhxcx9t
+lbMLQaIoCq9zWvMyh4oJzam5EjFyjpZDbh1w46ksJFcCgYAzxUaUkmkKQ8pSYfY3
+K3BY6N/PFb4N+xW4R0SNqd6skW6Q0BAkKMRy/L8YWRlTHZL/LsWOs3cBN0dReFdA
+nOhryKL4hBVLVTnY4o7nkCBig/CaMDHwXUrlZwoIVcVS/VQRWuMFS6DWD+QwDyV4
+Ja72UPQkTYbu0AnM1p2AjnQ1Sw==
+-----END PRIVATE KEY-----

+ 25 - 0
cert/localhost+1.pem

@@ -0,0 +1,25 @@
+-----BEGIN CERTIFICATE-----
+MIIEOTCCAqGgAwIBAgIQZXHBr+i1ZsyCh9EeIyBkWjANBgkqhkiG9w0BAQsFADB5
+MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExJzAlBgNVBAsMHlNPREMx
+NlxlbnpvQFNPUEMtMjNEMTAxIChFbnpvKTEuMCwGA1UEAwwlbWtjZXJ0IFNPREMx
+NlxlbnpvQFNPUEMtMjNEMTAxIChFbnpvKTAeFw0yNjA1MjUwNjI0NDZaFw0yODA4
+MjUwNjI0NDZaMFIxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZp
+Y2F0ZTEnMCUGA1UECwweU09EQzE2XGVuem9AU09QQy0yM0QxMDEgKEVuem8pMIIB
+IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2leNYWyGwuoQ06GmLzyiejqA
+GxxVq+TIvpgRFEcLvHYiR1v/YW6X+V0l8Gy9XzxCL1kfvGMFXGjTmBWMFg5koBk3
+iP0EFcU0dl4QXRKBfaMecUqsipoSXbcQ4k/iYm4WXr/YWeIlehI9Rb6/9/jLtxrW
+iV3BXp/3Fbe8bGy+a5pePwY2tlmqyuromX2gc5JYOwpG+EPEoTQfGTLuEW563kcs
+eHcwAMabhFmSxwwGQTPh/qxpyiNjzAbD2MHbx0bO+rvTih6jADzHXXv9tmL6oRQw
+54q5Otb1thEpobW1mbLlpOfp/pat10jJEaR4Hp8TRfU8cYW0qy9O0bccYdeW1QID
+AQABo2QwYjAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYD
+VR0jBBgwFoAUtrkpVhXxR16EF3vN5zAF08sezWQwGgYDVR0RBBMwEYIJbG9jYWxo
+b3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBgQB+7GdWjig52iRv4wdCLsML4yih
+BshBk5Jlo4HKjDqA9MQxjFfCO85b6pVbMTPg4gfCGcb10DQ5vp5Y3Hsp8u7bjM1t
+0/AVB/31NCIFGm8NEBAS+XMKRtsGTYFn2c24H2PEkiNe0Er2+1JbUVHjv19bYqB4
+ynefv3Yx1MBQRuqY9gDvs7tkmteIPK+jzL7v2ibkbmyOXUS1PvgsUgtZDVLBgXIQ
+lS9mS+5ZuMRyDRElojcjrnybPsRSyyvq/8YjPzAyp1sKBaG8VrNkMxbe8ANs0Tts
+k8qaVaX8sAZ9mHgQrJY26OxepYZJ+7f2LkUqIuk52Us0iKQ86QH9yctqU4/nHigL
+Kxuq5/bLISI+gYtNgxdJcOU6rKe75eSOH/VL39d38TDgVHI/vknHpLghuvD/mKjY
+jGBO7PJpHOfbjIZtwyeMmVFE8rsB7VOosnbJ9l+STaGM4UPvAo7ayTt9TBNxDutj
+URafNXQI63YUTU+bf4bhd3yeXdiz2FTribww0Cs=
+-----END CERTIFICATE-----

+ 0 - 2
src/app.module.ts

@@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
 import { AppController } from './app.controller';
 import { AppService } from './app.service';
 import { PalmOilModule } from './palm-oil/palm-oil.module';
-import { SurveillanceModule } from './surveillance/surveillance.module';
 
 @Module({
   imports: [
@@ -16,7 +15,6 @@ import { SurveillanceModule } from './surveillance/surveillance.module';
       synchronize: true,
     }),
     PalmOilModule,
-    SurveillanceModule,  // Lego 09 — boots PID polling on startup
   ],
   controllers: [AppController],
   providers: [AppService],

+ 2 - 2
src/main.ts

@@ -6,8 +6,8 @@ import * as path from 'path';
 
 async function bootstrap() {
   const httpsOptions = {
-    key: fs.readFileSync(path.resolve(__dirname, '../cert/127.0.0.1+1-key.pem')),
-    cert: fs.readFileSync(path.resolve(__dirname, '../cert/127.0.0.1+1.pem')),
+    key: fs.readFileSync(path.resolve(__dirname, '../cert/localhost+1-key.pem')),
+    cert: fs.readFileSync(path.resolve(__dirname, '../cert/localhost+1.pem')),
   };
 
   const app = await NestFactory.create(AppModule, { httpsOptions });

+ 2 - 2
src/palm-oil/interfaces/palm-analysis.interface.ts

@@ -10,8 +10,8 @@ export interface DetectionResult {
   class: string;
   confidence: number;
   is_health_alert: boolean;
-  box: [number, number, number, number]; // [x1, y1, x2, y2]
-  norm_box?: [number, number, number, number]; // Normalized
+  box: [number, number, number, number];      // Absolute pixels [x1, y1, x2, y2]
+  norm_box: [number, number, number, number]; // Normalized percentages [x1, y1, x2, y2]
 }
 
 export interface IndustrialSummary {

+ 1 - 2
src/palm-oil/palm-oil.module.ts

@@ -3,14 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
 import { PalmOilService } from './palm-oil.service';
 import { VisionGateway } from './vision.gateway';
 import { History } from './entities/history.entity';
-import { SurveillanceModule } from '../surveillance/surveillance.module';
 import { SCANNER_TOKEN } from './providers/scanner.interface';
 import { OnnxWasmProvider } from './providers/onnx-wasm.provider';
 
 console.log('🔧 Inference backend: onnx-wasm (Android/Termux)');
 
 @Module({
-  imports: [TypeOrmModule.forFeature([History]), SurveillanceModule],
+  imports: [TypeOrmModule.forFeature([History])],
   controllers: [],
   providers: [
     PalmOilService,

+ 33 - 0
src/palm-oil/palm-oil.service.ts

@@ -143,4 +143,37 @@ export class PalmOilService {
     await this.historyRepository.clear();
     return { deleted: records.length };
   }
+
+  async saveExternalResult(payload: {
+    frame: string;
+    filename?: string;
+    batchId?: string;
+    detections: any[];
+    industrial_summary: Record<string, number>;
+    inference_ms: number;
+    processing_ms?: number;
+  }): Promise<History> {
+    // Strip data URL prefix variants (e.g. "data:image/jpeg;base64,") before decoding
+    const rawBase64 = payload.frame.replace(/^data:image\/\w+;base64,/, '');
+    const imageBuffer = Buffer.from(rawBase64, 'base64');
+
+    const archiveId = `palm_edge_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
+    const fullPath = path.join(this.ARCHIVE_DIR, `${archiveId}.jpg`);
+    fs.writeFileSync(fullPath, imageBuffer);
+
+    const history = new History();
+    history.archive_id = archiveId;
+    history.filename = payload.filename ?? 'edge-frame';
+    history.image_path = fullPath;
+    // Guard against null/undefined so zero-detection frames persist cleanly
+    history.detections = Array.isArray(payload.detections) ? payload.detections : [];
+    history.total_count = history.detections.length;
+    history.industrial_summary = payload.industrial_summary ?? {};
+    history.inference_ms = parseFloat(payload.inference_ms.toFixed(2));
+    history.processing_ms = parseFloat((payload.processing_ms ?? 0).toFixed(2));
+    if (payload.batchId) history.batch_id = payload.batchId;
+
+    await this.historyRepository.save(history);
+    return history;
+  }
 }

+ 11 - 4
src/palm-oil/providers/onnx-wasm.provider.ts

@@ -91,16 +91,23 @@ export function postprocessShared(
     if (confidence < threshold) continue;
 
     const className = MPOB_CLASSES[Math.round(data[offset + 5])] || 'Unknown';
+
+    const nx1 = parseFloat(data[offset].toFixed(6));
+    const ny1 = parseFloat(data[offset + 1].toFixed(6));
+    const nx2 = parseFloat(data[offset + 2].toFixed(6));
+    const ny2 = parseFloat(data[offset + 3].toFixed(6));
+
     results.push({
       bunch_id: results.length + 1,
       class: className,
       confidence: parseFloat(confidence.toFixed(4)),
       is_health_alert: HEALTH_ALERT_CLASSES.includes(className),
+      norm_box: [nx1, ny1, nx2, ny2],
       box: [
-        data[offset] * originalWidth,
-        data[offset + 1] * originalHeight,
-        data[offset + 2] * originalWidth,
-        data[offset + 3] * originalHeight,
+        nx1 * originalWidth,
+        ny1 * originalHeight,
+        nx2 * originalWidth,
+        ny2 * originalHeight,
       ],
     });
   }

+ 45 - 72
src/palm-oil/vision.gateway.ts

@@ -29,11 +29,10 @@ import {
   MessageBody,
   ConnectedSocket,
 } from '@nestjs/websockets';
-import { Logger, OnModuleInit } from '@nestjs/common';
+import { Logger } from '@nestjs/common';
 import { Server, Socket } from 'socket.io';
 import { PalmOilService } from './palm-oil.service';
 import { AnalysisResponse } from './interfaces/palm-analysis.interface';
-import { SurveillanceService, SystemMetrics } from '../surveillance/surveillance.service';
 
 // ─── FIS Protocol Envelope ────────────────────────────────────────────────────
 
@@ -59,7 +58,6 @@ interface FisAppResponse {
   operation: string;
   complete: boolean;
   message?: string;   // JSON-encoded payload for data packets (complete: false)
-  payload?: any;      // Used only for push notifications (surveillance)
   error?: string;
 }
 
@@ -79,6 +77,16 @@ interface HistoryDeletePayload {
   archiveId: string;
 }
 
+interface EdgeResultPayload {
+  frame: string;
+  filename?: string;
+  batchId?: string;
+  detections: any[];
+  industrial_summary: Record<string, number>;
+  inference_ms: number;
+  processing_ms?: number;
+}
+
 // ─── Env ──────────────────────────────────────────────────────────────────────
 
 const N8N_WEBHOOK_URL = process.env['N8N_WEBHOOK_URL'] ?? '';
@@ -89,35 +97,17 @@ const N8N_WEBHOOK_URL = process.env['N8N_WEBHOOK_URL'] ?? '';
   cors: { origin: '*' },
 })
 export class VisionGateway
-  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleInit
+  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
 {
   @WebSocketServer()
   private server!: Server;
 
   private readonly logger = new Logger(VisionGateway.name);
 
-  constructor(
-    private readonly palmOilService: PalmOilService,
-    private readonly surveillanceService: SurveillanceService,
-  ) {}
+  constructor(private readonly palmOilService: PalmOilService) {}
 
   // ─── Lifecycle ───────────────────────────────────────────────────────────────
 
-  onModuleInit() {
-    this.surveillanceService.registerMetricsCallback((metrics: SystemMetrics) => {
-      const id = crypto.randomUUID();
-      const packet: FisAppResponse = {
-        id,
-        messageID: id,
-        serviceId: 'Surveillance',
-        operation: 'metricsUpdate',
-        complete: true,
-        payload: metrics,
-      };
-      this.server?.to('telemetry-room').emit('response', packet);
-    });
-  }
-
   afterInit() {
     this.logger.log('🔌 VisionGateway initialized on root namespace');
   }
@@ -125,24 +115,9 @@ export class VisionGateway
   handleConnection(client: Socket) {
     client.data.sessionId = crypto.randomUUID();
     this.logger.log(`📡 Vision client connected: ${client.id} (session: ${client.data.sessionId})`);
-
-    const snapshot = this.surveillanceService.getLatestMetrics();
-    if (snapshot) {
-      const snapId = crypto.randomUUID();
-      const packet: FisAppResponse = {
-        id: snapId,
-        messageID: snapId,
-        serviceId: 'Surveillance',
-        operation: 'metricsUpdate',
-        complete: true,
-        payload: snapshot,
-      };
-      client.emit('response', packet);
-    }
   }
 
   handleDisconnect(client: Socket) {
-    client.leave('telemetry-room');
     this.logger.log(`🔌 Vision client disconnected: ${client.id}`);
   }
 
@@ -155,15 +130,14 @@ export class VisionGateway
    * Routing key: `${serviceId}:${operation}`
    *
    * Supported routes:
-   *   PalmVision:analyze                  — decode Base64 frame, run ONNX, persist, reply
-   *   History:getAll                      — fetch last 50 history records
-   *   History:delete                      — delete one record by archiveId
-   *   History:clearAll                    — wipe all records and archived images
-   *   Chat:send                           — proxy message to n8n webhook
-   *   Chat:clear                          — reset session UUID
-   *   PalmHistory:GetImage                — stream archived image as Base64 data URL
-   *   Surveillance:SubscribeTelemetry     — join telemetry-room for metric push
-   *   Surveillance:UnsubscribeTelemetry   — leave telemetry-room
+   *   PalmVision:analyze              — decode Base64 frame, run ONNX, persist, reply
+   *   History:getAll                  — fetch last 50 history records
+   *   History:delete                  — delete one record by archiveId
+   *   History:clearAll                — wipe all records and archived images
+   *   Chat:send                       — proxy message to n8n webhook
+   *   Chat:clear                      — reset session UUID
+   *   PalmHistory:GetImage            — stream archived image as Base64 data URL
+   *   PalmHistory:SaveExternalResult  — persist edge-computed inference result to SQLite without re-running ONNX
    */
   @SubscribeMessage('request')
   async handleMessage(
@@ -316,6 +290,30 @@ export class VisionGateway
           break;
         }
 
+        // ── PalmHistory:SaveExternalResult ─────────────────────────────────
+        case 'PalmHistory:SaveExternalResult': {
+          const edgePayload = (payload ?? {}) as EdgeResultPayload;
+
+          if (!edgePayload.frame) {
+            replyError('No frame data received');
+            return;
+          }
+
+          const saved = await this.palmOilService.saveExternalResult(edgePayload);
+          this.logger.log(
+            `💾 PalmHistory:SaveExternalResult — SQLite write OK: ${saved.archive_id} | detections: ${saved.total_count} | edge_inference_ms: ${saved.inference_ms}`,
+          );
+
+          reply({
+            status: 'success',
+            archive_id: saved.archive_id,
+            total_count: saved.total_count,
+            inference_ms: saved.inference_ms,
+            created_at: saved.created_at,
+          });
+          break;
+        }
+
         // ── PalmHistory:GetImage ───────────────────────────────────────────
         case 'PalmHistory:GetImage': {
           const { archiveId } = (payload ?? {}) as HistoryDeletePayload;
@@ -343,31 +341,6 @@ export class VisionGateway
           break;
         }
 
-        // ── Surveillance:SubscribeTelemetry ────────────────────────────────
-        case 'Surveillance:SubscribeTelemetry': {
-          await client.join('telemetry-room');
-          this.logger.log(`📊 ${client.id} joined telemetry-room`);
-          const snapshot = this.surveillanceService.getLatestMetrics();
-          if (snapshot) {
-            const snapId = crypto.randomUUID();
-            client.emit('response', {
-              id: snapId, messageID: snapId,
-              serviceId: 'Surveillance', operation: 'metricsUpdate',
-              complete: true, payload: snapshot,
-            } satisfies FisAppResponse);
-          }
-          reply({ status: 'subscribed' });
-          break;
-        }
-
-        // ── Surveillance:UnsubscribeTelemetry ──────────────────────────────
-        case 'Surveillance:UnsubscribeTelemetry': {
-          await client.leave('telemetry-room');
-          this.logger.log(`📊 ${client.id} left telemetry-room`);
-          reply({ status: 'unsubscribed' });
-          break;
-        }
-
         // ── Unknown route ──────────────────────────────────────────────────
         default: {
           this.logger.warn(`⚠️  Unknown route: ${route}`);

+ 0 - 8
src/surveillance/surveillance.module.ts

@@ -1,8 +0,0 @@
-import { Module } from '@nestjs/common';
-import { SurveillanceService } from './surveillance.service';
-
-@Module({
-  providers: [SurveillanceService],
-  exports: [SurveillanceService],
-})
-export class SurveillanceModule {}

+ 0 - 155
src/surveillance/surveillance.service.ts

@@ -1,155 +0,0 @@
-import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
-import * as si from 'systeminformation';
-
-export type ServiceStatusType = 'ACTIVE' | 'IDLE' | 'OFFLINE';
-
-export interface ServiceStatus {
-  service: string; // 'nestjs' | 'n8n' | 'ollama'
-  pid: number | null;
-  status: ServiceStatusType;
-  cpu: number;    // % usage
-  memory: number; // bytes
-}
-
-export interface SystemMetrics {
-  cpuLoad: number;      // Total system CPU %
-  memUsed: number;      // Total system RAM used (bytes)
-  memTotal: number;     // Total system RAM (bytes)
-  uptime: number;       // System uptime in seconds
-  services: ServiceStatus[];
-  timestamp: Date;
-}
-
-// Keep legacy alias so the Gateway compiles without changes
-export type MonitorPayload = SystemMetrics;
-
-const normalizeCommand = (cmd: string | null | undefined): string => {
-  if (!cmd) return '';
-  return cmd.toLowerCase().replace(/\\/g, '/').trim();
-};
-
-// n8n is only monitorable when running locally — skip process tracking if the webhook points elsewhere
-const N8N_IS_LOCAL = (process.env['N8N_WEBHOOK_URL'] ?? '').toLowerCase().includes('localhost');
-
-// Matcher functions — nestjs uses PID identity; others use .some() over signature arrays for resilience
-const SERVICE_MATCHERS: Record<string, (p: si.Systeminformation.ProcessesProcessData) => boolean> = {
-  nestjs: (p) => p.pid === process.pid,
-  n8n:    (p) => { if (!N8N_IS_LOCAL) return false; const cmd = normalizeCommand(p.command); return ['n8n', 'n8n.exe', 'n8n-desktop'].some(sig => cmd.includes(sig)); },
-  ollama: (p) => { const cmd = normalizeCommand(p.command); return ['ollama', 'ollama_llama_server'].some(sig => cmd.includes(sig)); },
-};
-
-@Injectable()
-export class SurveillanceService implements OnModuleInit, OnModuleDestroy {
-  private readonly logger = new Logger(SurveillanceService.name);
-
-  private _latestMetrics: SystemMetrics | null = null;
-  private onMetricsUpdate: ((metrics: SystemMetrics) => void) | null = null;
-  private pollInterval: NodeJS.Timeout | null = null;
-
-  // ─── Lifecycle ─────────────────────────────────────────────────────────────
-
-  onModuleInit() {
-    this.logger.log('SurveillanceService booting — starting 1000ms poll loop');
-    this.pollInterval = setInterval(() => this.tick(), 1000);
-  }
-
-  onModuleDestroy() {
-    if (this.pollInterval) clearInterval(this.pollInterval);
-    this.logger.log('SurveillanceService stopped');
-  }
-
-  // ─── Public API ────────────────────────────────────────────────────────────
-
-  registerMetricsCallback(cb: (metrics: SystemMetrics) => void) {
-    this.onMetricsUpdate = cb;
-  }
-
-  getLatestMetrics(): SystemMetrics | null {
-    return this._latestMetrics;
-  }
-
-  // ─── Poll Tick ─────────────────────────────────────────────────────────────
-
-  private async tick() {
-    const [loadData, memData] = await Promise.all([si.currentLoad(), si.mem()]);
-
-    const cpuLoad = parseFloat(loadData.currentLoad.toFixed(2));
-    const memUsed = memData.used;
-    const memTotal = memData.total;
-    const uptime = Math.floor(si.time().uptime ?? 0);
-
-    let processList: si.Systeminformation.ProcessesProcessData[] = [];
-    try {
-      const result = await si.processes();
-      processList = result.list ?? [];
-    } catch {
-      // Fall through — processList stays empty, services will be offline
-    }
-
-    const services = this.buildServiceStatuses(processList);
-
-    const metrics: SystemMetrics = {
-      cpuLoad,
-      memUsed,
-      memTotal,
-      uptime,
-      services,
-      timestamp: new Date(),
-    };
-
-    this._latestMetrics = metrics;
-
-    if (this.onMetricsUpdate) {
-      this.onMetricsUpdate(metrics);
-    }
-  }
-
-  // ─── Helpers ───────────────────────────────────────────────────────────────
-
-  private buildServiceStatuses(
-    processList: si.Systeminformation.ProcessesProcessData[],
-  ): ServiceStatus[] {
-    const ACTIVE_CPU_THRESHOLD = 1.0;
-
-    const statuses = Object.entries(SERVICE_MATCHERS).map(([serviceName, matcher]) => {
-      if (processList.length === 0) {
-        return { service: serviceName, pid: null, status: 'OFFLINE' as ServiceStatusType, cpu: 0, memory: 0 };
-      }
-
-      const matches = processList.filter(matcher);
-
-      if (matches.length === 0) {
-        return { service: serviceName, pid: null, status: 'OFFLINE' as ServiceStatusType, cpu: 0, memory: 0 };
-      }
-
-      // Aggregate CPU and RSS memory across all matching processes
-      const { totalCpu, totalMem, firstPid } = matches.reduce(
-        (acc, p) => ({
-          totalCpu:  acc.totalCpu + (p.cpu ?? 0),
-          totalMem:  acc.totalMem + (p.memRss ?? 0),
-          firstPid:  acc.firstPid ?? p.pid,
-        }),
-        { totalCpu: 0, totalMem: 0, firstPid: null as number | null },
-      );
-
-      const status: ServiceStatusType = totalCpu > ACTIVE_CPU_THRESHOLD ? 'ACTIVE' : 'IDLE';
-
-      return {
-        service: serviceName,
-        pid:     firstPid,
-        status,
-        cpu:     parseFloat(totalCpu.toFixed(2)),
-        memory:  totalMem,
-      };
-    });
-
-    // Verification debug log — one line per service per tick
-    for (const s of statuses) {
-      this.logger.debug(
-        `[aggregator] ${s.service.padEnd(8)} | status=${s.status.padEnd(7)} | cpu=${s.cpu.toFixed(2).padStart(6)}% | mem=${s.memory} bytes | pid=${s.pid ?? 'none'}`,
-      );
-    }
-
-    return statuses;
-  }
-}