← 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.
Copy Download
# 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