← Kembali
Stabil Mahir Pemantauan

Sentinel Ruangan

Pemantauan 24/7 aman-privasi dengan self-healing — firmware yang dipakai armada data center kami.

Board
ESP32-S3-DevKitC-1
Perkiraan biaya
~$18
Waktu pasang
60 mnt
Sensor
WiFi CSI (56 subcarriers), phase + mag_h features, boot-reason telemetry

Sentinel produksi: menangkap CSI dalam mode promiscuous, mengirim satu frame 56-dimensi per detik ke server, mengirim heartbeat, dan — yang penting — menjaga dirinya tetap hidup tanpa diawasi berminggu-minggu. Lima lapis self-healing bawaan: watchdog WiFi 5 menit, reboot bila HTTP gagal beruntun, reboot terjadwal 12 jam, ring buffer RAM 60 frame yang menjembatani putusnya WiFi, dan pelaporan alasan boot supaya server bisa membedakan brownout dari panic. Semua penilaian anomali ada di server; perangkat hanya relay yang hati-hati, menampilkan ALIVE atau OFFLINE saja.

Dirawat dan diuji lapangan oleh tim Latent.

Mulai pemasangan terpandu

Yang kamu butuhkan

ESP32-S3-DevKitC-1 ~$12
Adaptor USB (5V stabil ≥1A) ~$4
Kabel USB-C ~$3
Perkiraan biaya~$18

Langkah pemasangan

1
Siapkan folder proyek
2
Arahkan ke server-mu
3
Verifikasi penanda firmware sebelum flash
4
Flash dan tempatkan perangkat
5
Biarkan bekerja — sungguh

Config perangkat

Config lengkap — salin, unduh, atau ikuti panduan pemasangan.

# dcmonitor — Cage Sentinel firmware (softmax-v1) # ================================================ # Manifest version: softmax-v1 # Required markers (FIRMWARE_MANIFEST.yaml): mag_h, phase, ALIVE/OFFLINE, # type:heartbeat, dcmonitor_buffer::push (H48 ring), no MOTION_HI/PRESENCE_HI. # # Role: # 1. Capture CSI in promiscuous mode (latentfield_csi component) # 2. POST raw 56-dim CSI per second to CRM, including phase + mag_h # (Wave 1.5 plumbing — server's per-node features depend on these) # 3. Send 10 s heartbeat UDP for rotary's fleet-liveness display # 4. Self-heal: WiFi watchdog 5 min, 12 h scheduled reboot # 5. Local persistence: 60-frame in-RAM ring buffer when WiFi down (Wave 7 H48) # 6. Calm display: only ALIVE / OFFLINE — no on-cage anomaly judgment # # All anomaly state lives server-side. The cage is a dumb-but-careful relay. esphome: name: dcmonitor-cage friendly_name: dcmonitor Cage Sentinel platformio_options: board_build.flash_mode: dio includes: - dcmonitor_udp.h # v1.6 — capture the previous boot's reset reason so the server can see # whether reboots are BROWNOUT (9), WDT (7), PANIC (4), EXT (2 = USB # power-cycle), SW (3 = our reboot_button), or POWERON (1). Stored in # a global and shipped with every CSI POST. on_boot: priority: 800 then: - lambda: |- id(boot_reason_int) = (int) esp_reset_reason(); esp32: board: esp32-s3-devkitc-1 variant: esp32s3 framework: type: esp-idf version: recommended sdkconfig_options: CONFIG_ESP_WIFI_CSI_ENABLED: "y" # v1.8 — task watchdog timeout 5s → 15s. Cages were cycling every # ~60s with boot=7 (WDT). Some path in our 1 Hz compute / POST chain # occasionally exceeds 5 s and trips the watchdog. 15 s gives the # firmware headroom while we instrument the actual blocking code. CONFIG_ESP_TASK_WDT_TIMEOUT_S: "15" logger: level: INFO wifi: ssid: !secret uplink_ssid password: !secret uplink_password power_save_mode: none reboot_timeout: 1800s # self-heal #1: wedged WiFi → reboot — bumped 300→1800s for AD-Guest auth-expiry roam tolerance http_request: verify_ssl: false timeout: 5s button: - platform: restart id: reboot_button name: "Reboot" # Single CSI POST script — fires from the 1 Hz tick or the H48 drain. # On success: zero the fail counter + bump post_count. # On error : bump fail counter (Wave 7 H48 logic gates POST vs ring on this). script: - id: post_csi mode: queued max_runs: 4 parameters: body: string then: - http_request.post: url: "https://crm.darksoc.org/api/dcmonitor/csi" body: !lambda 'return body;' request_headers: Content-Type: application/json on_response: - lambda: |- id(http_fail_count) = 0; id(post_count)++; on_error: - lambda: |- id(http_fail_count)++; ESP_LOGW("dcmonitor", "HTTP fail #%d", id(http_fail_count)); external_components: - source: type: local path: ../external_components components: [latentfield_csi] latentfield_csi: id: csi num_subcarriers: 56 ring_size: 8 # Power-only ring for the on-node ACF/MS extractor: 128 frames × 56 # subcarriers × float = 28 KB. Fits internal SRAM. At ~100 Hz capture # (rotary's 10 ms broadcast + AP beacons), this gives a 1.28-second # window — enough to resolve human-stride structure (1-2 Hz). power_ring_size: 128 min_rssi: -95 switch: - platform: gpio pin: { number: GPIO38, inverted: true } id: backlight name: "Display Backlight" restore_mode: ALWAYS_ON spi: clk_pin: GPIO5 mosi_pin: GPIO3 # Slim global set — connection telemetry only. NO detector state. globals: - { id: csi_frames, type: 'unsigned int', initial_value: '0' } - { id: csi_rssi, type: int, initial_value: '0' } - { id: strong_frames, type: 'unsigned int', initial_value: '0' } - { id: post_count, type: 'unsigned int', initial_value: '0' } - { id: http_fail_count, type: 'unsigned int', initial_value: '0' } - { id: posts_window_at, type: 'unsigned int', initial_value: '0' } - { id: posts_per_sec, type: float, initial_value: '0.0' } - { id: frames_window_at, type: 'unsigned int', initial_value: '0' } - { id: frames_per_sec, type: float, initial_value: '0.0' } # v1.4 — cached edge features for the display + heartbeat. Updated by a # 500 ms ticker that calls compute_edge_features() so the OLED shows the # ACTUAL motion statistic instead of a 10s post-rate that reads "0.0 /s" # in quiet windows. - { id: latest_ms, type: float, initial_value: '0.0' } - { id: latest_speed, type: float, initial_value: '0.0' } - { id: latest_fs, type: float, initial_value: '0.0' } - { id: latest_nf, type: 'unsigned int', initial_value: '0' } # v1.6 — esp_reset_reason() captured in on_boot. - { id: boot_reason_int, type: int, initial_value: '0' } # Calm display — connection telemetry only. NO red/amber based on local # measurement. Big real-time post rate is what reassures you data is # actually reaching the algorithms. display: - platform: st7735 model: INITR_MINI160X80 id: tiny_display cs_pin: GPIO4 dc_pin: GPIO2 reset_pin: GPIO1 device_width: 80 device_height: 160 col_start: 26 row_start: 1 rotation: 90 use_bgr: true invert_colors: true update_interval: 500ms lambda: |- auto ink = Color( 15, 30, 46); auto green = Color( 34, 197, 94); auto grey = Color(120, 115, 105); auto dim = Color( 80, 80, 90); auto blue = Color( 96, 165, 250); it.fill(ink); it.printf(80, 2, id(font_eyebrow), grey, TextAlign::TOP_CENTER, "CAGE A1"); it.line(20, 14, 140, 14, Color(60, 80, 100)); // v1.5 — looser OFFLINE threshold so transient WiFi roam events on // AD-Guest (auth-expiry blips, AP handover) don't flicker the display // every minute. Was 5 — at 1 Hz post rate that's ~5s of failure ≈ // a normal roam. 15 = ~15s sustained outage before we say OFFLINE. bool alive = (id(http_fail_count) < 15) && (id(strong_frames) > 0); Color stat_color = alive ? green : grey; const char *stat_text = alive ? "ALIVE" : "OFFLINE"; it.printf(80, 18, id(font_big), stat_color, TextAlign::CENTER, "%s", stat_text); // Big number = motion statistic (ρ at lag-1, MRC across subcarriers). // Always varies in real time — this is what makes the display useful. char ms_buf[16]; snprintf(ms_buf, sizeof(ms_buf), "%.2f", (double) id(latest_ms)); it.printf(80, 38, id(font_huge), blue, TextAlign::CENTER, "%s", ms_buf); it.printf(80, 50, id(font_tiny), dim, TextAlign::CENTER, "motion stat"); int rssi = id(csi_rssi); char counters[40]; // Speed (m/s) + RSSI on the third row. snprintf(counters, sizeof(counters), "%.1f m/s RSSI %d", (double) id(latest_speed), rssi); it.printf(80, 64, id(font_sub), grey, TextAlign::CENTER, "%s", counters); // fs + post total — fs proves edge-side capture rate, post total // confirms uplink keeps draining. char fr_buf[40]; snprintf(fr_buf, sizeof(fr_buf), "%.0f Hz %u sent", (double) id(latest_fs), (unsigned) id(post_count)); it.printf(80, 74, id(font_tiny), dim, TextAlign::CENTER, "%s", fr_buf); font: - file: "gfonts://Inter@900" id: font_huge size: 22 - file: "gfonts://Inter@800" id: font_big size: 16 - file: "gfonts://Inter@700" id: font_sub size: 11 - file: "gfonts://Inter@600" id: font_eyebrow size: 9 - file: "gfonts://Inter@500" id: font_tiny size: 7 interval: # Self-heal #4: scheduled 12-hour reboot (last-resort clean-state) - interval: 12h startup_delay: 12h then: - logger.log: { level: WARN, format: "12h scheduled reboot — self-heal #4" } - delay: 1s - button.press: reboot_button # Refresh ring-frame total for diagnostics - interval: 200ms then: - lambda: |- id(csi_frames) = id(csi).get_frame_count(); # Re-enable CSI capture as a safety net - interval: 5000ms then: - lambda: |- id(csi).reenable(); # Rolling 10s rate sampler — kept for heartbeat telemetry. - interval: 10000ms then: - lambda: |- unsigned int p_now = id(post_count); unsigned int f_now = id(strong_frames); id(posts_per_sec) = (p_now - id(posts_window_at)) / 10.0f; id(frames_per_sec) = (f_now - id(frames_window_at)) / 10.0f; id(posts_window_at) = p_now; id(frames_window_at) = f_now; # v1.5 — http_fail slow decay. on_response only resets on a SUCCESSFUL # POST; under sustained dedup or WiFi roam the counter could climb and # never come back. -1 every 60s ensures transient blips heal even when # no post succeeds in the meantime. - interval: 60000ms then: - lambda: |- if (id(http_fail_count) > 0) id(http_fail_count)--; # v1.4 — display globals are populated INSIDE the 1 Hz POST lambda below # (where compute_edge_features() already runs). A separate 500 ms ticker # was tried in early v1.4 but caused ~10 s reboot cycles due to heap # churn from the 28 KB temp ACF vector being allocated every 500 ms. # 1 Hz refresh is plenty for the OLED — ms doesn't change faster than # human-perceivable on screen. # Heartbeat UDP — rotary's UDP listener catches and forwards to CRM, # giving the rotary's "X/Y healthy" display per-node freshness data. # NO state field, NO M/P — server decides anomaly. - interval: 10000ms then: - lambda: |- char body[160]; int len = snprintf(body, sizeof(body), "{\"node\":\"cage-A1\",\"type\":\"heartbeat\"," "\"rssi\":%d,\"frames\":%u,\"posts\":%u,\"http_fail\":%u}", id(csi_rssi), (unsigned) id(strong_frames), (unsigned) id(post_count), (unsigned) id(http_fail_count)); if (len > 0) dcmonitor_udp::send_to("255.255.255.255", 9999, (const uint8_t *) body, (size_t) len); // v2.1 — diagnostic heartbeat for the WASM runtime. WARN level // because the wireless/USB logger filters INFO too aggressively // when joined post-boot; WARN comes through reliably. ESP_LOGW("wasm", "state=%u frames_run=%u errors=%u", (unsigned) id(csi).wasm_state(), (unsigned) id(csi).wasm_frames_run(), (unsigned) id(csi).wasm_errors()); # 1 Hz CSI POST — Wave 1.5 plumbing. # Body fields: # mag : raw magnitudes (back-compat, K floats) # mag_h : Hampel-filtered magnitudes (Wave 1 A5 — denoised on-device) # phase : CFO-corrected phase (Wave 1 A2/A3/A4 — radians, range (-π,π]) # Server reconstructs real/imag if needed: real = mag·cos(phase), imag = mag·sin(phase) # # Wave 7 H51 dedup: skip POST if magnitudes hash-match prior frame. # Wave 7 H48 ring : when http_fail_count ≥ 3, push to in-RAM ring (cap 60) # instead of queuing more failed POSTs. Drained below. - interval: 1000ms then: - lambda: |- if (millis() < 12000) return; // boot grace esphome::latentfield_csi::CSIFrame f; if (!id(csi).get_latest(f)) return; int K = (int) f.num_subcarriers; if (K < 8 || K > 64) return; if (f.rssi < -80) return; // H51 — FNV-1a Hamming-gate over int8-truncated magnitudes uint64_t h = 1469598103934665603ULL; for (int i = 0; i < K; i++) { int8_t v = (int8_t) f.magnitudes[i]; h ^= (uint64_t)(uint8_t) v; h *= 1099511628211ULL; } uint64_t &lh = dcmonitor_buffer::last_hash(); // v1.4 — dedup gate retired. The body now carries edge ACF/ms/speed // which vary continuously even when the int8-truncated raw mag // hash matches; suppressing those POSTs hides real motion physics. // Keep the hash counter for forensic dup_skips telemetry only. if (lh != 0 && h == lh) dcmonitor_buffer::dup_skips()++; lh = h; // v2.1 — tick the WASM on_timer hook BEFORE building the body so // the kernel's emits land in the event ring in time for this POST. id(csi).wasm_call_on_timer(); // v1.9 — body shrunk from ~3 KB (raw mag + mag_h + phase arrays) // to ~250 B (edge features only). The 3 KB std::string body was // root cause of the WDT (boot=7) reboot cascade: at 1 Hz with // queued POSTs, each ~3 KB allocation accumulated heap pressure // and after several minutes a single allocation took >15 s, // tripping the watchdog. Server analytics now consume acf/ms // directly. Raw arrays can be added back behind an event-driven // capture flag (Origin paper §6.3 idiom) if needed later. // v2.3 — bumped 512→768 B to accommodate the optional sv[] vector. char body[768]; int bp = 0; esphome::latentfield_csi::EdgeFeatures ef; // v2.0 — atomic snapshot from the dedicated edge_dsp task on // Core 1. Main loop NEVER runs ACF compute — that path is // entirely off the WiFi callback (Core 0) and off this main loop. bool ef_ok = id(csi).latest_features(ef); if (ef_ok) { id(latest_ms) = ef.ms; id(latest_speed) = ef.speed_mps; id(latest_fs) = ef.sample_hz; id(latest_nf) = ef.n_frames; bp = snprintf(body, sizeof(body), "{\"node\":\"cage-A1\",\"ts\":%lu,\"rssi\":%d," "\"acf\":[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]," "\"ms\":%.4f,\"speed\":%.3f,\"fs\":%.1f," "\"nf\":%u,\"boot\":%d}", (unsigned long) millis(), (int) f.rssi, ef.acf[0], ef.acf[1], ef.acf[2], ef.acf[3], ef.acf[4], ef.acf[5], ef.acf[6], ef.ms, ef.speed_mps, ef.sample_hz, (unsigned) ef.n_frames, id(boot_reason_int)); } else { bp = snprintf(body, sizeof(body), "{\"node\":\"cage-A1\",\"ts\":%lu,\"rssi\":%d," "\"boot\":%d}", (unsigned long) millis(), (int) f.rssi, id(boot_reason_int)); } if (bp <= 0 || bp >= (int) sizeof(body)) return; // v2.3 — splice per-subcarrier validity before evt[] so server // can distinguish wideband jamming from spectrum-selective // interference. Leading comma; replaces the trailing `}`. if (body[bp-1] == '}') { size_t sn = id(csi).format_subcarrier_validity_json( body + bp - 1, (size_t) (sizeof(body) - (bp - 1) - 1)); if (sn > 0) { bp = (int) ((bp - 1) + sn); body[bp++] = '}'; body[bp] = '\0'; } } // v2.1 — overwrite the trailing `}` with `,"evt":[…]}` if the WASM // kernel emitted anything since the last POST. Leaves the body // untouched when nothing is pending. if (body[bp-1] == '}') { size_t en = id(csi).wasm_format_events_json( body + bp - 1, (size_t) (sizeof(body) - (bp - 1) - 1)); if (en > 0) { bp = (int) ((bp - 1) + en); body[bp++] = '}'; body[bp] = '\0'; } } std::string body_s(body, (size_t) bp); // H48 — uplink wedged: park most into ring, probe 1-in-10 so // http_fail can self-clear when the network returns. if (id(http_fail_count) >= 3) { static uint32_t probe = 0; if ((probe++ % 10) == 0) { id(post_csi).execute(body_s); } else { dcmonitor_buffer::push(body_s); } return; } id(post_csi).execute(body_s); # H48 drain — replay ringed frames once the link returns. - interval: 1000ms then: - lambda: |- if (id(http_fail_count) != 0) return; auto &q = dcmonitor_buffer::ring(); if (q.empty()) return; int n = 0; while (!q.empty() && n < 5) { std::string body = q.front(); q.pop_front(); id(post_csi).execute(body); n++; } # 50 ms tick — only updates RSSI + frame count for the display - interval: 50ms then: - lambda: |- static uint32_t last_seq = 0; esphome::latentfield_csi::CSIFrame f; if (!id(csi).get_latest(f)) return; if (f.seq == last_seq) return; last_seq = f.seq; if (f.rssi < -80) return; id(csi_rssi) = (int) f.rssi; id(strong_frames)++;

Ulasan (0)

Belum ada ulasan — jadilah yang pertama.

Masuk dulu untuk fitur ini — hanya 20 detik dan gratis. Masuk