diff --git a/silico/knowledge/esp32-audio.md b/silico/knowledge/esp32-audio.md index 53feade..3e3b302 100644 --- a/silico/knowledge/esp32-audio.md +++ b/silico/knowledge/esp32-audio.md @@ -148,9 +148,42 @@ For product audio that must sound better than bit-bang DAC: Do not copy keypad/Web-UI surfaces into silico; extract only host techniques that every GCU might need. +## Two C paths to the built-in DAC — pick with the board, not the docs + +On `language=c` GCUs there are **two** ways to feed the classic-ESP32 internal DAC, and the better one is board-dependent: + +| Path | Use when | Measured trouble | +|------|----------|------------------| +| `dac_continuous` (DMA) | Short-to-medium PCM; boards where it stays fed | **M5GO: descriptor timeouts under load** on multi-minute streams | +| **I2S → built-in DAC** (`I2S_MODE_DAC_BUILT_IN`) | Multi-minute streams, or when `dac_continuous` times out | Channel-format trap below — silently plays **2× fast** | + +Do not treat either as *the* C answer. Measure on the board, then record which one you shipped and why. Both sections below are live guidance. + +### I2S → built-in DAC (measured on M5GO / classic ESP32) + +The reliable long-stream path on M5-class cores. Shape that worked: + +```text +mode = I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_DAC_BUILT_IN +bits_per_sample = 16 # DAC takes the top 8 bits +channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT # NOT ONLY_RIGHT — see trap +communication_fmt = I2S_COMM_FORMAT_STAND_MSB +dma_buf_count = 8 +dma_buf_len = 256 +use_apll = true +tx_desc_auto_clear = true +then: i2s_set_dac_mode(I2S_DAC_CHANNEL_BOTH_EN) +``` + +**The 2× fast trap (costs hours if you do not know it).** With `I2S_CHANNEL_FMT_ONLY_RIGHT`, content clocks about **twice as fast** as its true rate on this path. The symptom looks exactly like a sample-rate bug, so the natural (wrong) fix is to halve the configured rate — which then makes every other rate assumption in the product wrong. **Correct fix:** use `I2S_CHANNEL_FMT_RIGHT_LEFT` and duplicate the mono sample into both L and R slots. Suspect channel format *before* you touch the asset's sample rate. + +**Feeding u8 mono.** Expand each unsigned byte to a signed 16-bit frame written to both channels. Digital gain belongs here, and it has a ceiling: on the M5 amp **4× clipped audibly; 2× was desk-loud and clean**. Gain is product behavior — put the factor in the shipped defaults table, not a literal buried in the expander, and keep the comment honest about which factor actually shipped. + +**Stopping cleanly.** Park to **u8 mid (128 → `0x8000` in a 16-bit frame)** and stop there if more audio follows. Writing mid and then hard-writing **zero** steps the DAC to rail and **clicks** — the same cliff described under *Silence is hard*, just one abstraction up. Ramp mid → 0 if you truly want the pin at 0 V. + ## ESP-IDF `dac_continuous` (C / native path) — queue depth is product behavior -Native ESP-IDF continuous DAC (DMA) is the right path for multi-minute PCM on `language=c` GCUs. **Queue depth is audible product behavior**, not a free tuning knob (#79 field report). +Native ESP-IDF continuous DAC (DMA) is a good path for PCM on `language=c` GCUs **where it stays fed** — see the table above before committing to it on an M5-class board. **Queue depth is audible product behavior**, not a free tuning knob (#79 field report). ### Queue depth vs pause / teardown @@ -196,10 +229,14 @@ returns. ### Agent checklist (C audio) +- [ ] Path chosen by **measurement** on this board (`dac_continuous` vs I2S→DAC), and the reason recorded. +- [ ] If I2S→DAC: `RIGHT_LEFT` + mono duplicated. Pitch wrong? Suspect **channel format before sample rate**. +- [ ] Digital gain measured against the board's amp, and stored in the shipped defaults table. - [ ] Queue depth chosen for **pause UX**, not max underrun margin alone. -- [ ] Stop path **drains** before disable. +- [ ] Stop path **drains** before disable; park at **mid**, never hard-zero after mid. - [ ] Rate from header + probe validation. - [ ] Operator forewarning before long boot riffs (AGENTS: announce surprising metal effects). +- [ ] UI paint cost checked against the feeder: per-pixel `fill_rect` text rendering can issue tens of thousands of SPI transactions per second and starve the audio task. Compose regions and blit once. ## Deploy / CDC interaction @@ -240,3 +277,7 @@ Keep results in the GCU (script + notes). Promote durable bullets **here**. - NeoPixel side-strip updates interleaved with DAC sample loops coupled noise into the amp; static sides during long PCM helped. - Streaming u8 mono from flash in ~1 KiB chunks with button polls between/inside chunks allows pause without loading the whole file into RAM. - `period = 1_000_000 // 11025` floors to 90 µs (~0.78% fast); use rounded period or remainder-accumulator (`base` + distribute `1e6 % hz`) for correct pitch/duration. +- **C / M5GO:** `dac_continuous` hit descriptor timeouts under load on multi-minute streams; I2S → built-in DAC (`I2S_MODE_DAC_BUILT_IN`, 8 × 256 DMA, APLL) streamed the full track reliably (measured 2026-07). +- **C / classic ESP32:** `I2S_CHANNEL_FMT_ONLY_RIGHT` on the built-in-DAC path clocked content **~2× fast**; `I2S_CHANNEL_FMT_RIGHT_LEFT` with the mono sample duplicated into both slots fixed pitch. The symptom mimics a sample-rate error and burned several commits chasing 44.1 k vs 22.05 k before the channel format was found. +- **C / M5 amp:** 4× digital gain on u8 PCM distorted; 2× was desk-loud and clean. +- Parking an I2S DAC at u8 mid and then writing a zero frame re-introduced the click that parking at mid exists to avoid. diff --git a/silico/plates/gcu-c/AGENTS.md b/silico/plates/gcu-c/AGENTS.md index 5534572..e045928 100644 --- a/silico/plates/gcu-c/AGENTS.md +++ b/silico/plates/gcu-c/AGENTS.md @@ -88,7 +88,7 @@ exactly that; keep that seed when you extend the domain. `silico doctor` reads it; check before installing another IDF. - More: `silico/knowledge/macos-codex-esp-idf.md`. -## Identity (required on the link) +## Link command surface (identity + escape hatch) **Boot-print alone is not enough** for `silico inspect` after a greeting or banner scrolls past (#78 / #79). The image **must answer** the host word `identity` (CR/LF framed) with: @@ -98,4 +98,27 @@ fw_name=GCU fw_version=0.0.1 Plate `main.c` shows the pattern: print once at boot **and** respond when the host knocks. A boot-print-only app is invisible to inspect as soon as the banner is gone. -Escape hatch (`repl` / `reboot`) is a product requirement for reclaim without hard reset when possible. +`identity`, `repl`, and `reboot` all ship in the plate. The escape hatch is **not optional decoration**: `repl` parks outputs and releases the console so a host can redeploy without hardware gymnastics, and `reboot` parks then hard-resets. A build without the door cannot be reclaimed on a bench. + +### Parsing lives in the domain, not in main.c + +`gcu_parse_command` / `gcu_handle_command` are in `src/domain.c`; `firmware/main/main.c` only moves bytes. Keep it that way: + +1. `silico inspect` knocks **`identity`** and nothing else. Whatever else your product declares is verified by **your** host tests or not at all — so put the surface where a host test can reach it. +2. A dispatcher inside `firmware/` is device-only code, which means "protocol parsing" can never be part of a host-green claim. + +`host/test_protocol.c` covers identity, `repl` parking, deferred `reboot`, blank lines, and unknown input failing closed. **Add a row for every command your product spec declares** — including the ones that must be refused. If your spec says the listed commands are the complete surface, do not quietly ship a fourth one (diagnostic capture hooks included). + +Outputs your board drives get quieted in the HAL's `park_outputs` — extend it as the product grows a speaker, strips, or actuators. `repl` handing back a console while the product is still singing is a defect. + +## Product defaults and host coverage + +`silico product-path` proves a host scenario **loads** `[host].product_defaults` — it does not prove your spec's normative behavior is covered. Those are different claims. When the product spec has normative tables (button map, state machine, cycle order, screen layout), give each one a host test; the plate's `host/` files are a **floor to build on, not a ceiling**. Three shipped test files is what the plate happens to need, not what your product needs. + +Anything unmeasurable in the spec ("smooth", "comfortable", "a debounce") becomes a number the moment you implement it. Put it in `include/gcu/defaults.h` and tell the operator you chose it — do not let the choice exist only in your head. + +## Display HAL granularity (screens) + +If the product face is a screen, the HAL's drawing primitives are a performance contract, not a formality. Compose a region into a buffer and **blit it once**; do not render text or glyphs by calling a per-pixel `fill_rect` in a loop. A 5x7 glyph drawn pixel-by-pixel is ~35 SPI transactions (and ~35 allocations if the backend allocates per call) **per character** — a six-row sensor readout at 10 Hz becomes tens of thousands of transactions per second, which is exactly the load that fights an audio DMA feeder and shows up as stutter the operator can hear. + +Keep partial paints regional (eye only, banner strip only, value fields only) and reserve full-screen clears for mode changes. diff --git a/silico/plates/gcu-c/firmware/main/hal_board.c b/silico/plates/gcu-c/firmware/main/hal_board.c index 0f82443..a49f7b7 100644 --- a/silico/plates/gcu-c/firmware/main/hal_board.c +++ b/silico/plates/gcu-c/firmware/main/hal_board.c @@ -2,6 +2,7 @@ #include "hal_board.h" #include "driver/gpio.h" +#include "esp_system.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -27,10 +28,25 @@ static int64_t now_ms(gcu_hal_t *self) { return esp_timer_get_time() / 1000; } +/* Escape hatch: quiet everything this board drives. Extend as the product + * grows outputs (speaker to parked level, strips off, actuators safe) — + * `repl` must not hand the console back with the product still singing. */ +static void park_outputs(gcu_hal_t *self) { + (void)self; + gpio_set_level(GCU_LED_GPIO, 0); +} + +static void board_reboot(gcu_hal_t *self) { + (void)self; + esp_restart(); +} + static gcu_hal_t board_hal = { .set_led = set_led, .delay_ms = delay_ms, .now_ms = now_ms, + .park_outputs = park_outputs, + .reboot = board_reboot, }; gcu_hal_t *gcu_make_board_hal(void) { diff --git a/silico/plates/gcu-c/firmware/main/main.c b/silico/plates/gcu-c/firmware/main/main.c index 70fb164..113c95c 100644 --- a/silico/plates/gcu-c/firmware/main/main.c +++ b/silico/plates/gcu-c/firmware/main/main.c @@ -12,9 +12,15 @@ #include /* + * Link plumbing only. Parsing and dispatch live in portable domain code + * (gcu_handle_command) so the command surface is host-testable — this file + * moves bytes, it does not decide what a command means. + * * Identity on the link (#78 / #79): boot-print alone is not enough for * silico inspect after the greeting scrolls past. The app must also answer * the host word "identity" (CR/LF framed) with fw_name=… fw_version=…. + * `repl` and `reboot` are required alongside it — a build without the + * escape hatch cannot be reclaimed without hardware gymnastics. * * stdin MUST be non-blocking before the forever loop. Blocking getchar() * would park app_main and kill the product face (tick/LED) until a host @@ -35,7 +41,7 @@ static void stdin_set_nonblocking(void) { } } -static void drain_identity_command(void) { +static void drain_link_commands(gcu_state_t *st) { static char line[48]; static int n; int c; @@ -48,15 +54,10 @@ static void drain_identity_command(void) { while ((c = getchar()) != EOF) { if (c == '\r' || c == '\n') { if (n > 0) { + char reply[80]; line[n] = '\0'; - char *p = line; - while (*p && isspace((unsigned char)*p)) { - p++; - } - if (strcmp(p, "identity") == 0) { - char id[64]; - gcu_identity_line(id, (int)sizeof id); - printf("%s\n", id); + if (gcu_handle_command(st, line, reply, (int)sizeof reply)) { + printf("%s\n", reply); fflush(stdout); } n = 0; @@ -98,7 +99,14 @@ void app_main(void) { gcu_init(&st, hal); for (;;) { - drain_identity_command(); + drain_link_commands(&st); + if (st.reboot_pending) { + /* Reply already flushed above; outputs already parked by the domain. */ + st.reboot_pending = 0; + if (hal && hal->reboot) { + hal->reboot(hal); + } + } gcu_tick(&st); if (hal && hal->delay_ms) { hal->delay_ms(hal, gcu_tick_sleep_ms(&st)); diff --git a/silico/plates/gcu-c/host/CMakeLists.txt b/silico/plates/gcu-c/host/CMakeLists.txt index 5d4e9a8..085e986 100644 --- a/silico/plates/gcu-c/host/CMakeLists.txt +++ b/silico/plates/gcu-c/host/CMakeLists.txt @@ -18,6 +18,10 @@ add_executable(test_time test_time.c) target_link_libraries(test_time gcu_core) add_test(NAME time64 COMMAND test_time) +add_executable(test_protocol test_protocol.c) +target_link_libraries(test_protocol gcu_core) +add_test(NAME protocol COMMAND test_protocol) + # Convenience: cmake --build build/host --target host_test runs ctest. # Do not name this target "test" — CMake reserves that name when enable_testing() is on. # Multi-config generators (Visual Studio) need -C; single-config (Ninja) use no -C. @@ -29,6 +33,6 @@ else() endif() add_custom_target(host_test COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure ${_gcu_ctest_config_args} - DEPENDS test_defaults test_time + DEPENDS test_defaults test_time test_protocol WORKING_DIRECTORY ${CMAKE_BINARY_DIR} ) diff --git a/silico/plates/gcu-c/host/test_protocol.c b/silico/plates/gcu-c/host/test_protocol.c new file mode 100644 index 0000000..4e6606c --- /dev/null +++ b/silico/plates/gcu-c/host/test_protocol.c @@ -0,0 +1,165 @@ +/* Link command surface — host test (no hardware). + * + * Why this file exists: the escape hatch (`repl` / `reboot`) is a product + * requirement, and an escape hatch that is only ever exercised on metal is + * the one that turns out to be missing at the worst moment. Parsing and + * dispatch live in src/domain.c precisely so this test can run on the host. + * + * Extend this alongside the product's declared command surface: every command + * the product spec lists should have a row here, including the ones that must + * FAIL (unknown input fails closed with a short error, not a help essay). + */ +#include "gcu/defaults.h" +#include "gcu/domain.h" +#include "gcu/hal.h" +#include "gcu/version.h" + +#include +#include + +static int led_state; +static int parked_calls; +static int reboot_calls; + +static void set_led(gcu_hal_t *self, int on) { + (void)self; + led_state = on; +} + +static void delay_ms(gcu_hal_t *self, int ms) { + (void)self; + (void)ms; +} + +static void park_outputs(gcu_hal_t *self) { + (void)self; + parked_calls++; +} + +static void board_reboot(gcu_hal_t *self) { + (void)self; + reboot_calls++; +} + +static int fail(const char *msg) { + fprintf(stderr, "FAIL: %s\n", msg); + return 1; +} + +int main(void) { + gcu_hal_t hal = { + .set_led = set_led, + .delay_ms = delay_ms, + .park_outputs = park_outputs, + .reboot = board_reboot, + }; + gcu_state_t st; + char reply[80]; + + /* --- parsing: whole tokens, surrounding whitespace tolerated --- */ + if (gcu_parse_command("identity") != GCU_CMD_IDENTITY) { + return fail("identity not parsed"); + } + if (gcu_parse_command(" repl \r\n") != GCU_CMD_REPL) { + return fail("repl not parsed with surrounding whitespace"); + } + if (gcu_parse_command("reboot") != GCU_CMD_REBOOT) { + return fail("reboot not parsed"); + } + if (gcu_parse_command("") != GCU_CMD_NONE || + gcu_parse_command(" ") != GCU_CMD_NONE) { + return fail("blank line should be NONE"); + } + if (gcu_parse_command(NULL) != GCU_CMD_NONE) { + return fail("NULL line should be NONE"); + } + /* Prefix/substring must not match a command. */ + if (gcu_parse_command("identityX") != GCU_CMD_UNKNOWN || + gcu_parse_command("rep") != GCU_CMD_UNKNOWN) { + return fail("partial token matched a command"); + } + + /* --- identity --- */ + gcu_init(&st, &hal); + if (!gcu_handle_command(&st, "identity", reply, (int)sizeof reply)) { + return fail("identity produced no reply"); + } + if (strstr(reply, "fw_name=") == NULL || strstr(reply, "fw_version=") == NULL) { + return fail("identity reply missing fw_name/fw_version"); + } + if (st.parked) { + return fail("identity must not park outputs"); + } + + /* --- blank line: no reply, no chatter on the link --- */ + if (gcu_handle_command(&st, " ", reply, (int)sizeof reply)) { + return fail("blank line should produce no reply"); + } + + /* --- unknown fails closed and short --- */ + if (!gcu_handle_command(&st, "sing", reply, (int)sizeof reply)) { + return fail("unknown command produced no reply"); + } + if (strncmp(reply, "err", 3) != 0) { + return fail("unknown command should reply with a short error"); + } + if (st.parked) { + return fail("unknown command must not park outputs"); + } + + /* --- repl parks outputs and releases the console --- */ + gcu_init(&st, &hal); + led_state = 1; + parked_calls = 0; + if (!gcu_handle_command(&st, "repl", reply, (int)sizeof reply)) { + return fail("repl produced no reply"); + } + if (!st.parked) { + return fail("repl did not set parked"); + } + if (parked_calls != 1) { + return fail("repl did not call hal park_outputs"); + } + if (led_state != 0) { + return fail("repl left the LED driven"); + } + if (st.reboot_pending) { + return fail("repl must not request a reboot"); + } + /* Parked means parked: further ticks do not resume driving the face. */ + led_state = 1; + gcu_tick(&st); + gcu_tick(&st); + if (led_state != 1) { + return fail("tick drove outputs after repl parked them"); + } + + /* --- reboot parks, acks, and defers the reset to main --- */ + gcu_init(&st, &hal); + parked_calls = 0; + reboot_calls = 0; + if (!gcu_handle_command(&st, "reboot", reply, (int)sizeof reply)) { + return fail("reboot produced no reply"); + } + if (parked_calls != 1) { + return fail("reboot did not park outputs"); + } + if (!st.reboot_pending) { + return fail("reboot did not set reboot_pending"); + } + if (reboot_calls != 0) { + return fail("domain must not reset before the reply is flushed"); + } + + /* --- undersized reply buffer is refused, not overflowed --- */ + gcu_init(&st, &hal); + { + char tiny[4]; + if (gcu_handle_command(&st, "identity", tiny, (int)sizeof tiny)) { + return fail("undersized buffer should be refused"); + } + } + + printf("OK protocol identity+repl+reboot+unknown\n"); + return 0; +} diff --git a/silico/plates/gcu-c/include/gcu/domain.h b/silico/plates/gcu-c/include/gcu/domain.h index 0d10481..132b509 100644 --- a/silico/plates/gcu-c/include/gcu/domain.h +++ b/silico/plates/gcu-c/include/gcu/domain.h @@ -11,11 +11,35 @@ typedef struct { int led_on; int tick_sleep_ms; int64_t last_blink_ms; /* wall-clock blink edge; unused without now_ms */ + int parked; /* `repl` released product ownership of outputs */ + int reboot_pending; /* `reboot` acked; main resets after flushing */ } gcu_state_t; +/* Link command surface. + * + * Parsing and dispatch live HERE, in portable domain code, not in + * firmware/main.c — otherwise "protocol parsing" cannot be host-tested and + * the escape hatch is only ever exercised on metal. See host/test_protocol.c. + * Products extend this enum; keep the shipped surface equal to what the + * product spec declares. */ +typedef enum { + GCU_CMD_NONE = 0, /* blank line — no reply */ + GCU_CMD_IDENTITY, + GCU_CMD_REPL, + GCU_CMD_REBOOT, + GCU_CMD_UNKNOWN, /* fails closed with a short error */ +} gcu_cmd_t; + void gcu_identity_line(char *out, int out_len); void gcu_init(gcu_state_t *st, gcu_hal_t *hal); void gcu_tick(gcu_state_t *st); int gcu_tick_sleep_ms(const gcu_state_t *st); +gcu_cmd_t gcu_parse_command(const char *line); + +/* Handle one link line. Returns 1 when *out holds a reply to write, else 0. + * `repl` and `reboot` park outputs through the HAL before replying. */ +int gcu_handle_command(gcu_state_t *st, const char *line, char *out, + int out_len); + #endif diff --git a/silico/plates/gcu-c/include/gcu/hal.h b/silico/plates/gcu-c/include/gcu/hal.h index 6ae4189..1e5956e 100644 --- a/silico/plates/gcu-c/include/gcu/hal.h +++ b/silico/plates/gcu-c/include/gcu/hal.h @@ -15,6 +15,19 @@ struct gcu_hal { * millisecond math in `long` overflows in <10 h and wraps at ~24.8 days. * Host `long` is 64-bit and hides the trap — see host/test_time.c. */ int64_t (*now_ms)(gcu_hal_t *self); + /* Escape hatch (optional hooks; NULL is tolerated). + * + * park_outputs: quiet every product output this board drives — speaker to + * a parked level, LEDs/strips off, actuators safe. Called for `repl` and + * `reboot` so the operator never gets a redeploy that is still singing. + * Extend this on the product backend as outputs are added; the domain + * cannot know what your board drives. + * + * reboot: hard reset (e.g. esp_restart). The domain never calls this + * directly — it sets gcu_state_t.reboot_pending so the link reply can be + * flushed first. See firmware/main/main.c. */ + void (*park_outputs)(gcu_hal_t *self); + void (*reboot)(gcu_hal_t *self); }; #endif diff --git a/silico/plates/gcu-c/install/README.md b/silico/plates/gcu-c/install/README.md index 4674d52..67f22c6 100644 --- a/silico/plates/gcu-c/install/README.md +++ b/silico/plates/gcu-c/install/README.md @@ -14,3 +14,9 @@ silico deploy --port COMx --yes --verify ``` Requires ESP-IDF (`idf.py` or `IDF_PATH`). First flash and app update use the same image path. + +## Data-partition assets + +If the product ships payloads outside the app image (audio, LUTs, calibration), declare them as `[[deploy.data]]` in `silico.toml`. `silico deploy` plans and flashes them with the image, behind the same `--yes` confirm. + +Do **not** paste a raw `esptool write_flash` line into this file as the operator's path. An asset flashed by a hand-run command is an asset that goes missing on the next bench — and the firmware then has to guess what to do about it. Silico grew `[[deploy.data]]` (tig/silico#79) specifically to delete that command wall. diff --git a/silico/plates/gcu-c/silico.toml b/silico/plates/gcu-c/silico.toml index 46e2b03..e56c4df 100644 --- a/silico/plates/gcu-c/silico.toml +++ b/silico/plates/gcu-c/silico.toml @@ -23,3 +23,19 @@ allow_device_headers = ["hal_board"] [deploy] mode = "idf-flash" project = "firmware" + +# Data-partition payloads (songs, LUTs, calibration blobs) ride along with the +# app image: `silico deploy` plans them and flashes after --yes, same confirm +# manners as the image. Use this instead of hand-rolling an esptool +# write_flash line into your install docs — a command wall in a README is the +# thing this replaces, and an asset flashed by hand is an asset that silently +# goes missing on the next operator's bench. +# +# `partition` is the name column in firmware/partitions.csv; `offset` is only +# needed when there is no matching row to resolve. +# +# [[deploy.data]] +# name = "song" +# file = "assets/first.u8.raw" +# partition = "storage" +# # offset = "0x210000" diff --git a/silico/plates/gcu-c/src/domain.c b/silico/plates/gcu-c/src/domain.c index 076e0b0..ed23ccc 100644 --- a/silico/plates/gcu-c/src/domain.c +++ b/silico/plates/gcu-c/src/domain.c @@ -3,6 +3,7 @@ #include "gcu/version.h" #include +#include void gcu_identity_line(char *out, int out_len) { if (!out || out_len < 8) { @@ -18,6 +19,8 @@ void gcu_init(gcu_state_t *st, gcu_hal_t *hal) { st->led_on = 0; st->tick_sleep_ms = GCU_DEFAULTS.tick_sleep_ms; st->last_blink_ms = (hal && hal->now_ms) ? hal->now_ms(hal) : 0; + st->parked = 0; + st->reboot_pending = 0; } static void toggle_led(gcu_state_t *st) { @@ -29,6 +32,11 @@ static void toggle_led(gcu_state_t *st) { void gcu_tick(gcu_state_t *st) { st->tick_count += 1; + /* After `repl` the host owns the console and the outputs stay parked — + * do not resume driving the product face until the next boot. */ + if (st->parked) { + return; + } if (st->hal && st->hal->now_ms) { /* Wall-clock blink: robust to variable tick latency. All millisecond * math stays in int64_t — `long` is 32-bit on ESP32 (see hal.h). */ @@ -43,3 +51,86 @@ void gcu_tick(gcu_state_t *st) { } int gcu_tick_sleep_ms(const gcu_state_t *st) { return st->tick_sleep_ms; } + +/* ---------------------------------------------------------------- link --- */ + +static const char *skip_ws(const char *s) { + while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') { + s++; + } + return s; +} + +/* Whole-token match: "identity" hits, "identityX" and "id" do not. */ +static int token_is(const char *s, const char *word) { + size_t n = strlen(word); + if (strncmp(s, word, n) != 0) { + return 0; + } + return *skip_ws(s + n) == '\0'; +} + +gcu_cmd_t gcu_parse_command(const char *line) { + if (!line) { + return GCU_CMD_NONE; + } + const char *p = skip_ws(line); + if (*p == '\0') { + return GCU_CMD_NONE; + } + if (token_is(p, "identity")) { + return GCU_CMD_IDENTITY; + } + if (token_is(p, "repl")) { + return GCU_CMD_REPL; + } + if (token_is(p, "reboot")) { + return GCU_CMD_REBOOT; + } + return GCU_CMD_UNKNOWN; +} + +static void park_outputs(gcu_state_t *st) { + if (!st || !st->hal) { + return; + } + if (st->hal->set_led) { + st->hal->set_led(st->hal, 0); + } + st->led_on = 0; + /* Board backend quiets anything else it drives (speaker, strips, motion). */ + if (st->hal->park_outputs) { + st->hal->park_outputs(st->hal); + } +} + +int gcu_handle_command(gcu_state_t *st, const char *line, char *out, + int out_len) { + if (!st || !out || out_len < 16) { + return 0; + } + switch (gcu_parse_command(line)) { + case GCU_CMD_IDENTITY: + gcu_identity_line(out, out_len); + return 1; + case GCU_CMD_REPL: + park_outputs(st); + st->parked = 1; + snprintf(out, (size_t)out_len, "ok repl"); + return 1; + case GCU_CMD_REBOOT: + park_outputs(st); + st->parked = 1; + /* main.c flushes this reply, then calls hal->reboot. */ + st->reboot_pending = 1; + snprintf(out, (size_t)out_len, "ok reboot"); + return 1; + case GCU_CMD_UNKNOWN: + /* Fail closed with a short error; never a multi-line help essay. */ + snprintf(out, (size_t)out_len, "err unknown"); + return 1; + case GCU_CMD_NONE: + default: + return 0; + } +}