← Back
Stable Advanced Presence sensing

Multi-Modal Sensor Hub

CSI + microphone + IMU + ultrasonic + round display — a lab bench in one knob.

Board
Elecrow CrowPanel 1.28" ESP32-S3 Rotary
Est. cost
~$45
Install time
90 min
Sensors
WiFi CSI, I2S microphone, MPU6050 IMU, ultrasonic, rotary encoder

The flagship sensing device: an Elecrow CrowPanel 1.28" rotary display running five sensing modalities at once — WiFi CSI (56 subcarriers), I2S microphone, MPU6050 accelerometer, ultrasonic ranging, and a rotary knob — plus a tone/chirp/noise generator and an ESP-NOW mesh radio. Everything speaks a clean JSON-lines protocol over USB, so any script on your computer can read sensors and drive the round display. This is the collector node of our field mesh.

Maintained and field-tested by the Latent team.

Start guided install

What you need

Elecrow CrowPanel 1.28" ESP32-S3 Rotary Display ~$30
MPU6050 accelerometer module ~$3
HC-SR04 ultrasonic module ~$3
I2S microphone (e.g. INMP441) ~$5
Jumper wires + USB-C cable ~$4
Est. cost~$45

Install steps

1
Know the power-enable trick
2
Wire the external sensors
3
Flash with the external components
4
Talk to it in JSON

Device config

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

# LatentField — ESP32 Rotary Display Firmware v2 # ================================================ # Adds three custom external_components to the v1 firmware: # - latentfield_csi WiFi Channel State Info capture (56 subcarriers) # - latentfield_espnow Peer-to-peer mesh sync across sensor nodes # - latentfield_probe Tone/chirp/noise generator (LEDC-PWM; ESP32-S3 has no true DAC) # # Hardware: Elecrow CrowPanel 1.28" ESP32-S3 Rotary Display # Flash: esphome run latentfield_rotary_v2.yaml # Connect: USB-C to Mac, 115200 baud, JSON lines over UART0 # # Protocol (bridge → board, one JSON object per line): # {"mode":"sense"} → board replies with full sensor JSON # {"mode":"display","type":"glow","r":255,"g":179,"b":71,"text":"...","subtitle":"..."} # {"mode":"display","type":"clear"} # {"mode":"emit","type":"tone","freq":432,"duration":1000,"volume":50} # {"mode":"emit","type":"chirp","start":200,"end":4000,"duration":500} # {"mode":"emit","type":"noise","duration":200} # {"mode":"mesh","op":"status"} → espnow status JSON # {"mode":"mesh","op":"broadcast","freq":432,"score":49.0} # # All replies are single-line JSON terminated with '\n'. substitutions: device_name: "latentfield-rotary" friendly: "LatentField Sensor" probe_pin: "GPIO12" # Node ID for ESP-NOW mesh. Change per device (0..255). node_id: "1" esphome: name: ${device_name} friendly_name: ${friendly} platformio_options: board_build.flash_mode: dio # ArduinoJson not required — we do minimal hand-rolled parsing inline. esp32: board: esp32-s3-devkitc-1 framework: type: esp-idf version: recommended sdkconfig_options: # Enable CSI in the IDF kconfig so esp_wifi_set_csi(true) is available. CONFIG_ESP_WIFI_CSI_ENABLED: "y" # ---------- External components ---------- external_components: - source: type: local path: external_components components: [latentfield_csi, latentfield_espnow, latentfield_probe] # ---------- WiFi ---------- wifi: ssid: !secret wifi_ssid password: !secret wifi_password power_save_mode: none # CSI hates power save # ---------- Serial bridge to the Mac ---------- uart: id: mac_uart tx_pin: GPIO43 rx_pin: GPIO44 baud_rate: 115200 rx_buffer_size: 2048 logger: level: INFO baud_rate: 0 # We own UART0 for the JSON protocol # ---------- Sensors ---------- i2s_audio: i2s_lrclk_pin: GPIO6 i2s_bclk_pin: GPIO5 microphone: - platform: i2s_audio id: mic adc_type: external i2s_din_pin: GPIO7 pdm: false bits_per_sample: 16bit channel: left i2c: sda: GPIO8 scl: GPIO9 scan: true sensor: - platform: mpu6050 address: 0x68 update_interval: 100ms accel_x: { name: "Accel X", id: accel_x } accel_y: { name: "Accel Y", id: accel_y } accel_z: { name: "Accel Z", id: accel_z } - platform: ultrasonic trigger_pin: GPIO10 echo_pin: GPIO11 name: "Distance" id: ultrasonic_dist update_interval: 500ms - platform: rotary_encoder name: "Knob" id: knob pin_a: GPIO2 pin_b: GPIO1 resolution: 1 min_value: 0 max_value: 100 # ---------- Display ---------- spi: clk_pin: GPIO18 mosi_pin: GPIO17 display: - platform: gc9a01 id: round_display cs_pin: GPIO15 dc_pin: GPIO16 reset_pin: GPIO14 dimensions: { width: 240, height: 240 } rotation: 0 update_interval: 100ms lambda: |- auto bg = Color(id(bg_r), id(bg_g), id(bg_b)); it.fill(bg); if (id(display_mode) == 0) { int cx = 120, cy = 120; int radius = 80 + (int)(10.0 * sin(id(pulse_phase))); auto glow_color = Color(id(glow_r), id(glow_g), id(glow_b)); it.filled_circle(cx, cy, radius, glow_color); it.printf(cx, 190, id(font_name), glow_color, TextAlign::CENTER, "%s", id(display_text).c_str()); it.printf(cx, 210, id(font_sub), Color(128,128,128), TextAlign::CENTER, "%s", id(display_subtitle).c_str()); } else if (id(display_mode) == 1) { auto accent = Color(255, 179, 71); it.printf(120, 80, id(font_name), accent, TextAlign::CENTER, "%s", id(input_prompt).c_str()); it.printf(120, 130, id(font_big), Color(255,255,255), TextAlign::CENTER, "%d", id(input_value)); } font: - file: "gfonts://Manrope@700" id: font_name size: 18 - file: "gfonts://Manrope@400" id: font_sub size: 12 - file: "gfonts://Manrope@800" id: font_big size: 48 # ---------- LatentField custom components ---------- latentfield_csi: id: csi num_subcarriers: 56 ring_size: 4 min_rssi: -85 # filter_mac: AA:BB:CC:DD:EE:FF # Optional — restrict to one tx node latentfield_espnow: id: mesh node_id: ${node_id} role: peer channel: 0 # 0 = use whatever channel wifi manager picks ring_size: 16 # peers: # - 30:AE:A4:12:34:56 # - 30:AE:A4:12:34:57 latentfield_probe: id: probe pin: ${probe_pin} ledc_channel: 6 carrier_hz: 80000 sample_rate_hz: 16000 amplitude: 0.6 # ---------- Globals ---------- globals: - { id: display_mode, type: int, initial_value: '0' } - { id: glow_r, type: int, initial_value: '255' } - { id: glow_g, type: int, initial_value: '179' } - { id: glow_b, type: int, initial_value: '71' } - { id: bg_r, type: int, initial_value: '10' } - { id: bg_g, type: int, initial_value: '10' } - { id: bg_b, type: int, initial_value: '14' } - { id: pulse_phase, type: float, initial_value: '0' } - { id: display_text, type: std::string, initial_value: '""' } - { id: display_subtitle, type: std::string, initial_value: '""' } - { id: input_prompt, type: std::string, initial_value: '""' } - { id: input_value, type: int, initial_value: '50' } - { id: rx_buf, type: std::string, initial_value: '""' } - { id: cmd_count, type: unsigned int, initial_value: '0' } # ---------- Serial protocol dispatcher ---------- interval: # Pulse animation for glow mode. - interval: 50ms then: - lambda: |- id(pulse_phase) += 0.05f; if (id(pulse_phase) > 6.283f) id(pulse_phase) = 0.0f; # UART reader — pulls bytes from the Mac, dispatches complete JSON lines. - interval: 20ms then: - lambda: !lambda |- // Minimal JSON extractor — handles our flat protocol. auto find_val = [](const std::string &s, const char *key) -> std::string { std::string k; k.reserve(32); k += '"'; k += key; k += '"'; size_t pos = s.find(k); if (pos == std::string::npos) return ""; pos = s.find(':', pos); if (pos == std::string::npos) return ""; pos++; while (pos < s.size() && (s[pos] == ' ' || s[pos] == '\t')) pos++; if (pos >= s.size()) return ""; if (s[pos] == '"') { size_t end = s.find('"', pos + 1); if (end == std::string::npos) return ""; return s.substr(pos + 1, end - pos - 1); } size_t end = pos; while (end < s.size() && (isdigit((unsigned char)s[end]) || s[end]=='.' || s[end]=='-' || s[end]=='+')) end++; return s.substr(pos, end - pos); }; auto to_int = [](const std::string &v, int d) { return v.empty() ? d : atoi(v.c_str()); }; auto to_float = [](const std::string &v, float d){ return v.empty() ? d : (float)atof(v.c_str()); }; // Drain UART into line buffer. while (id(mac_uart).available()) { uint8_t b; if (!id(mac_uart).read_byte(&b)) break; if (b == '\r') continue; if (b == '\n') { std::string line = id(rx_buf); id(rx_buf) = ""; if (line.empty()) continue; id(cmd_count)++; std::string mode = find_val(line, "mode"); std::string reply; if (mode == "sense") { // Assemble the full sensor JSON. CSI magnitudes+phases come from // latentfield_csi. Accel/ultrasonic/knob come from ESPHome sensors. std::string csi_json = id(csi).get_latest_json(); char tail[256]; snprintf(tail, sizeof(tail), ",\"accel\":[%.3f,%.3f,%.3f],\"ultrasonic\":%.1f,\"knob\":%d,\"seq\":%u}", id(accel_x).state, id(accel_y).state, id(accel_z).state, id(ultrasonic_dist).state, (int)id(knob).state, (unsigned)id(cmd_count)); // Splice: csi_json ends in "}" — replace with fields above. if (!csi_json.empty() && csi_json.back() == '}') csi_json.pop_back(); reply = csi_json + tail; } else if (mode == "display") { std::string type = find_val(line, "type"); if (type == "glow") { id(display_mode) = 0; id(glow_r) = to_int(find_val(line, "r"), 255); id(glow_g) = to_int(find_val(line, "g"), 179); id(glow_b) = to_int(find_val(line, "b"), 71); id(display_text) = find_val(line, "text"); id(display_subtitle) = find_val(line, "subtitle"); reply = "{\"ok\":true,\"type\":\"glow\"}"; } else if (type == "clear") { id(display_text) = ""; id(display_subtitle) = ""; id(glow_r) = 0; id(glow_g) = 0; id(glow_b) = 0; reply = "{\"ok\":true,\"type\":\"clear\"}"; } else { reply = "{\"ok\":false,\"err\":\"unknown display type\"}"; } } else if (mode == "emit") { std::string type = find_val(line, "type"); int duration = to_int(find_val(line, "duration"), 500); float vol = to_float(find_val(line, "volume"), -1.0f); if (vol > 1.0f) vol = vol / 100.0f; // accept 0..100 or 0..1 if (type == "tone") { float freq = to_float(find_val(line, "freq"), 432.0f); id(probe).tone(freq, duration, vol); reply = "{\"ok\":true,\"type\":\"tone\"}"; } else if (type == "chirp") { float a = to_float(find_val(line, "start"), 200.0f); float b = to_float(find_val(line, "end"), 4000.0f); id(probe).chirp(a, b, duration, vol); reply = "{\"ok\":true,\"type\":\"chirp\"}"; } else if (type == "noise") { id(probe).noise(duration, vol); reply = "{\"ok\":true,\"type\":\"noise\"}"; } else if (type == "stop") { id(probe).stop(); reply = "{\"ok\":true,\"type\":\"stop\"}"; } else { reply = "{\"ok\":false,\"err\":\"unknown emit type\"}"; } } else if (mode == "mesh") { std::string op = find_val(line, "op"); if (op == "status") { reply = std::string("{\"status\":") + id(mesh).get_status_json() + ",\"peers\":" + id(mesh).get_peers_json() + "}"; } else if (op == "ping") { id(mesh).send_ping(); reply = "{\"ok\":true,\"type\":\"ping\"}"; } else if (op == "broadcast") { float score = to_float(find_val(line, "score"), 0.0f); int zone = to_int(find_val(line, "zone"), 0); id(mesh).send_anomaly(score, (uint8_t) zone); reply = "{\"ok\":true,\"type\":\"anomaly\"}"; } else { reply = "{\"ok\":false,\"err\":\"unknown mesh op\"}"; } } else if (mode == "input") { id(display_mode) = 1; id(input_prompt) = find_val(line, "prompt"); int vmin = to_int(find_val(line, "min"), 0); int vmax = to_int(find_val(line, "max"), 100); if (id(input_value) < vmin) id(input_value) = vmin; if (id(input_value) > vmax) id(input_value) = vmax; reply = "{\"ok\":true,\"value\":" + std::to_string(id(input_value)) + "}"; } else { reply = "{\"ok\":false,\"err\":\"unknown mode\"}"; } reply += "\n"; id(mac_uart).write_array( reinterpret_cast<const uint8_t*>(reply.data()), reply.size()); } else { if (id(rx_buf).size() < 2048) id(rx_buf) += (char) b; } }

Reviews (0)

No reviews yet — be the first.

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