← 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
Device config Full config — copy it, download it, or follow the guided install.
Copy Download
# 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