← Kembali
Stabil Mahir Deteksi kehadiran

Hub Sensor Multi-Modal

CSI + mikrofon + IMU + ultrasonik + layar bundar — satu meja lab dalam satu kenop.

Board
Elecrow CrowPanel 1.28" ESP32-S3 Rotary
Perkiraan biaya
~$45
Waktu pasang
90 mnt
Sensor
WiFi CSI, I2S microphone, MPU6050 IMU, ultrasonic, rotary encoder

Perangkat sensing andalan: Elecrow CrowPanel 1.28" rotary display yang menjalankan lima modalitas sensing sekaligus — WiFi CSI (56 subcarrier), mikrofon I2S, akselerometer MPU6050, pengukur jarak ultrasonik, dan kenop putar — plus generator nada/chirp/noise dan radio mesh ESP-NOW. Semuanya bicara protokol JSON-lines yang rapi lewat USB, jadi skrip apa pun di komputermu bisa membaca sensor dan mengendalikan layar bundar. Ini node kolektor mesh lapangan kami.

Dirawat dan diuji lapangan oleh tim Latent.

Mulai pemasangan terpandu

Yang kamu butuhkan

Elecrow CrowPanel 1.28" ESP32-S3 Rotary Display ~$30
Modul akselerometer MPU6050 ~$3
Modul ultrasonik HC-SR04 ~$3
Mikrofon I2S (mis. INMP441) ~$5
Kabel jumper + kabel USB-C ~$4
Perkiraan biaya~$45

Langkah pemasangan

1
Kenali trik power-enable
2
Kabeli sensor eksternal
3
Flash dengan komponen eksternal
4
Ajak bicara lewat JSON

Config perangkat

Config lengkap — salin, unduh, atau ikuti panduan pemasangan.

# 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; } }

Ulasan (0)

Belum ada ulasan — jadilah yang pertama.

Masuk dulu untuk fitur ini — hanya 20 detik dan gratis. Masuk