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
Elecrow CrowPanel 1.28" ESP32-S3 Rotary
Perkiraan biaya
~$45
~$45
Waktu pasang
90 mnt
90 mnt
Sensor
WiFi CSI, I2S microphone, MPU6050 IMU, ultrasonic, rotary encoder
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 terpanduYang 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