← 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 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.
Salin Unduh
# 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