← Back
Stable Advanced Monitoring

Room Sentinel

Privacy-safe 24/7 monitoring with self-healing — the firmware our data-center fleet runs.

Board
ESP32-S3-DevKitC-1
Est. cost
~$18
Install time
60 min
Sensors
WiFi CSI (56 subcarriers), phase + mag_h features, boot-reason telemetry

The production sentinel: captures CSI in promiscuous mode, posts one 56-dimension frame per second to a server, sends heartbeats, and — crucially — keeps itself alive unattended for weeks. Five self-healing layers are built in: a 5-minute WiFi watchdog, HTTP fail-count reboot, a 12-hour scheduled reboot, a 60-frame RAM ring buffer that bridges WiFi outages, and boot-reason reporting so the server can tell a brownout from a panic. All anomaly judgment lives server-side; the device is a dumb-but-careful relay showing only ALIVE or OFFLINE.

Maintained and field-tested by the Latent team.

Start guided install

What you need

ESP32-S3-DevKitC-1 ~$12
USB power adapter (stable 5V ≥1A) ~$4
USB-C cable ~$3
Est. cost~$18

Install steps

1
Prepare the project folder
2
Point it at your server
3
Verify firmware markers before flashing
4
Flash and place the device
5
Leave it alone — really

Device config

Full config — copy it, download it, or follow the guided install.

# 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)++;

Reviews (0)

No reviews yet — be the first.

Sign in to use this feature — it takes 20 seconds and it’s free. Sign in