diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..71d97ed --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,66 @@ + + +## What & why + + + +## Type of change + + + +- [ ] `feat` — new functionality +- [ ] `fix` — bug fix +- [ ] `docs` — documentation only +- [ ] `test` — a gate / HIL / host test +- [ ] `chore` / `ci` / `refactor` + +## ⛔ R1 — does anything run on or above the DAP path? + + + +- [ ] **No** — this change is host-only, docs-only, or lands entirely on the **+0** dashboard/SD task. +- [ ] **Yes** — and here is how it stays non-blocking (no `sleep` / busy-wait / blocking I/O / DAP-needed lock on the hot path; slow work is posted to the owning task): + +> _…explain…_ + +## Checklist + +- [ ] **Did not edit `upstream/`** — customization is by overlay (new file in `src/`, or a shadowed copy added to `CMakeLists.txt` and documented in its header). +- [ ] **One owner per resource** — FatFs↔SD task, OLED/I2C1↔dashboard task, uart0 TX↔bridge task; no cross-task reach (post an async request instead). +- [ ] **No new silent drop / silent pass** — every bounded buffer has a counted overflow surfaced in `{"q":"status"}`; every new test can actually fail. +- [ ] Commits are **small, atomic, conventional-commit** (`feat(scope): …`), and each builds + passes the gate on its own. +- [ ] Every commit is **signed off** (`git commit -s` — [DCO](https://developercertificate.org/)). +- [ ] Shared hooks enabled (`git config core.hooksPath .githooks`) so personal AI-assistant context files stay out of the repo. +- [ ] New first-party files under `firmware/c/` carry an `SPDX-License-Identifier: MIT` header; no copyleft/non-commercial dep added to the shipping image. +- [ ] README / docs updated if a CDC1 command, dashboard screen, or build flag changed. + +## Test evidence + + + +- [ ] **CI green** — `Firmware (C probe fork)` (build + `analyze.sh` gate) and `Host unit tests` (portable logic). +- [ ] **Host tests** run locally: + +``` +$ cc -I src tests/... && ./a.out +…paste output… +``` + +- [ ] **HIL** — attested on the candidate image, or noted as `needs bench: `: + +``` +…paste the *_hil.py PASS lines, or the GATE_RESULTS.md / *_RESULTS.md entry… +``` diff --git a/README.md b/README.md index 8d1d656..235d79f 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,11 @@ Send `{"q":"", ...}\n`; you get one JSON line back. | **Tools** | `{"q":"macros"}` / `{"q":"macro","i":N}` | list / send a predefined string out the target UART | | | `{"q":"setmacro","i":N,"s":"..."}` | set macro N (persisted to SD) | | | `{"q":"baud","v":N}` | change the target-UART baud (validated set; persisted) | -| **Feedback** | `{"q":"beep"}` / `{"q":"led",...}` / `{"q":"pixel",...}` | buzzer / status LEDs / NeoPixel | +| **Feedback** | `{"q":"beep"}` / `{"q":"led",...}` / `{"q":"pixel",...}` | buzzer / status LEDs / NeoPixel (pixel 0) | | | `{"q":"fb"}` | feedback-layer readback — beep count + live NeoPixel colour (diagnostic) | +| **Companion (v1.2)** | `{"q":"fill","r":..,"g":..,"b":..}` | set the whole WS2812 chain to one colour | +| | `{"q":"mood","n":0..6,"i":0..255}` | animated mood: off / idle / rx / warn / fault / pet / rainbow | +| | `{"q":"btn"}` / `{"q":"joy"}` | button + joystick readback (held / taps; present / X / Y / dir) | | **Companion** | `{"q":"pet"}` | a happy cat beat (heart + chirp) | | | `{"q":"summon"}` / `{"q":"banish"}` | force the ghost present / absent (also lets tests drive it) | | | `{"q":"exorcise"}` | the exorcism dissolve — a host flasher fires it after a clean reflash | diff --git a/firmware/c/CMakeLists.txt b/firmware/c/CMakeLists.txt index 84d4eca..f07d489 100644 --- a/firmware/c/CMakeLists.txt +++ b/firmware/c/CMakeLists.txt @@ -51,6 +51,13 @@ option(HG_PIN_DAP "Pin the DAP/USB transaction hot path into SRAM (XIP-cache-con # CI passes the workflow `version` input. Default marks an untagged local/dev build. set(HG_VERSION "0.0.0-dev" CACHE STRING "Hackagotchi firmware semver reported by {\"q\":\"status\"}") +# v1.2 "Companion" accessories (CyberBrick kit). ALL feature-gated — the default build (count=1, button +# OFF, joystick OFF) is behaviourally identical to v1.1 and must be re-gated on hardware before shipping +# with any of these ON. See docs/private/hackagotchi-v1.2-assembly-guide.html. +set(HG_NEOPIXEL_COUNT 1 CACHE STRING "WS2812 pixels in the chain (1=onboard only; >1 = external strip on GP12)") +option(HG_BUTTON "Enable the momentary button on GP16 (freed onboard green LED)" OFF) +option(HG_JOYSTICK "Enable the XA011 joystick via ADS1115 on the Grove I2C bus (drops I2C1 to 400 kHz)" OFF) + # [HACKAGOTCHI] M2: carlk3 no-OS-FatFS-SD (SDIO+SPI) for the SD black-box recorder. Fetched by # setup.sh into the gitignored upstream/ (same pattern as debugprobe), NOT a submodule. INTERFACE lib; # our src/sd/hw_config.c provides the SPI0 pin map. Requires pico_sdk_init() to have run (above). @@ -85,6 +92,10 @@ add_executable(hackagotchi_probe ${CMAKE_CURRENT_LIST_DIR}/src/recorder.c # M2: black-box recorder core (host-tested) ${CMAKE_CURRENT_LIST_DIR}/src/i2c1_bus.c # I2C1 bring-up (OLED-only, FM+ 1 MHz, no mutex) ${CMAKE_CURRENT_LIST_DIR}/src/feedback.c # M3.0: status LEDs (GP17/16) + buzzer (GP29) HAL + ${CMAKE_CURRENT_LIST_DIR}/src/neopixel_anim.c # v1.2: NeoPixel mood animation (pure, host-tested) + ${CMAKE_CURRENT_LIST_DIR}/src/input_logic.c # v1.2: button debounce + joystick decode (pure) + ${CMAKE_CURRENT_LIST_DIR}/src/ads1115.c # v1.2: ADS1115 I2C ADC (joystick on the Grove bus) + ${CMAKE_CURRENT_LIST_DIR}/src/hg_input.c # v1.2: button (GP16) + joystick HW glue (+0 tasks) ${CMAKE_CURRENT_LIST_DIR}/src/hg_config.c # M4: runtime config store (macros, baud) ${CMAKE_CURRENT_LIST_DIR}/src/dap_health.c # DAP transfer/health witness (via --wrap below) # --- pristine upstream debugprobe sources --- @@ -135,10 +146,19 @@ target_compile_definitions(hackagotchi_probe PRIVATE PICO_RP2040_USB_DEVICE_ENUMERATION_FIX=1 ADVERSARIAL_STALL_MS=${ADVERSARIAL_STALL_MS} HG_VERSION="${HG_VERSION}" # CMake escapes the quotes -> a C string literal + HG_NEOPIXEL_COUNT=${HG_NEOPIXEL_COUNT} # v1.2: WS2812 chain length (default 1 = v1.1 onboard pixel) ) if(ADVERSARIAL_AT_DAP_PRIO) target_compile_definitions(hackagotchi_probe PRIVATE ADVERSARIAL_AT_DAP_PRIO=1) endif() +# v1.2 input features (default OFF -> the new modules compile but stay dormant; HG_JOYSTICK also drops the +# I2C1 bus to 400 kHz in i2c1_bus.h). Re-gate on hardware before shipping with either ON. +if(HG_BUTTON) + target_compile_definitions(hackagotchi_probe PRIVATE HG_BUTTON=1) +endif() +if(HG_JOYSTICK) + target_compile_definitions(hackagotchi_probe PRIVATE HG_JOYSTICK=1) +endif() target_link_libraries(hackagotchi_probe PRIVATE pico_multicore diff --git a/firmware/c/build_fork.sh b/firmware/c/build_fork.sh index faa4162..f2b7f9b 100755 --- a/firmware/c/build_fork.sh +++ b/firmware/c/build_fork.sh @@ -23,6 +23,11 @@ ADVERSARIAL_AT_DAP_PRIO="${ADVERSARIAL_AT_DAP_PRIO:-OFF}" # Pin the DAP/USB transaction hot path into SRAM (XIP-cache-contention fix). ON = v1.1+ shipping default; # set HG_PIN_DAP=OFF to reproduce the pre-fix XIP image for an A/B soak. HG_PIN_DAP="${HG_PIN_DAP:-ON}" +# v1.2 "Companion" accessories (CyberBrick kit). Defaults = v1.1 behaviour. After soldering per +# docs/private/hackagotchi-v1.2-assembly-guide.html, e.g.: HG_NEOPIXEL_COUNT=5 HG_BUTTON=ON HG_JOYSTICK=ON ./build_fork.sh +HG_NEOPIXEL_COUNT="${HG_NEOPIXEL_COUNT:-1}" # WS2812 chain length (1 = onboard pixel only) +HG_BUTTON="${HG_BUTTON:-OFF}" # momentary button on GP16 +HG_JOYSTICK="${HG_JOYSTICK:-OFF}" # XA011 joystick via ADS1115 on the Grove I2C bus # M5: release semver compiled into the firmware (reported by {"q":"status"} as "ver"). Override with # VERSION=1.0.0 ./build_fork.sh ; CI passes the workflow `version` input. Default = untagged dev build. HG_VERSION="${VERSION:-${HG_VERSION:-0.0.0-dev}}" @@ -46,6 +51,7 @@ echo "[build] gcc : $(arm-none-eabi-gcc --version | head -1)" echo "[build] pico-sdk : $PICO_SDK_PATH" echo "[build] stall : ADVERSARIAL_STALL_MS=$ADVERSARIAL_STALL_MS" echo "[build] pin-dap : HG_PIN_DAP=$HG_PIN_DAP" +echo "[build] companion: HG_NEOPIXEL_COUNT=$HG_NEOPIXEL_COUNT HG_BUTTON=$HG_BUTTON HG_JOYSTICK=$HG_JOYSTICK" echo "[build] version : HG_VERSION=$HG_VERSION" echo "[build] out dir : $BUILD_DIR" @@ -57,6 +63,9 @@ cmake "$HERE" \ -DADVERSARIAL_AT_DAP_PRIO="$ADVERSARIAL_AT_DAP_PRIO" \ -DHG_PIN_DAP="$HG_PIN_DAP" \ -DHG_VERSION="$HG_VERSION" \ + -DHG_NEOPIXEL_COUNT="$HG_NEOPIXEL_COUNT" \ + -DHG_BUTTON="$HG_BUTTON" \ + -DHG_JOYSTICK="$HG_JOYSTICK" \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON make -j diff --git a/firmware/c/src/ads1115.c b/firmware/c/src/ads1115.c new file mode 100644 index 0000000..7b7ed1e --- /dev/null +++ b/firmware/c/src/ads1115.c @@ -0,0 +1,31 @@ +/* Hackagotchi — ADS1115 I2C ADC driver. SPDX-License-Identifier: MIT (see header) */ +#include "ads1115.h" +#include + +#define ADS_REG_CONV 0x00u +#define ADS_REG_CONF 0x01u +#define ADS_TIMEOUT_US 2000 + +bool ads1115_read(i2c_inst_t *i2c, uint8_t addr, uint8_t channel, int16_t *out) { + if (!out || channel > 3u) return false; + + // OS=1 (start) | MUX=100+ch (AINx vs GND) | PGA=001 (±4.096V) | MODE=1 (single-shot) + // | DR=111 (860 SPS) | COMP_QUE=11 (comparator off). + uint16_t mux = (uint16_t)(0x4u | channel); + uint16_t cfg = (uint16_t)(0x8000u | (uint16_t)(mux << 12) | (0x1u << 9) | (0x1u << 8) | (0x7u << 5) | 0x3u); + + uint8_t w[3] = { ADS_REG_CONF, (uint8_t)(cfg >> 8), (uint8_t)(cfg & 0xFFu) }; + if (i2c_write_timeout_us(i2c, addr, w, 3, false, ADS_TIMEOUT_US) != 3) return false; + + // 860 SPS -> ~1.16 ms conversion; wait with margin. sleep_us busy-waits but is fully preemptible on the + // +0 task (it never masks IRQs), so the DAP/USB path is unaffected (R1). + sleep_us(1500); + + uint8_t reg = ADS_REG_CONV; + if (i2c_write_timeout_us(i2c, addr, ®, 1, true, ADS_TIMEOUT_US) != 1) return false; + uint8_t rd[2]; + if (i2c_read_timeout_us(i2c, addr, rd, 2, false, ADS_TIMEOUT_US) != 2) return false; + + *out = (int16_t)(((uint16_t)rd[0] << 8) | (uint16_t)rd[1]); + return true; +} diff --git a/firmware/c/src/ads1115.h b/firmware/c/src/ads1115.h new file mode 100644 index 0000000..e7b98ee --- /dev/null +++ b/firmware/c/src/ads1115.h @@ -0,0 +1,20 @@ +/* + * Hackagotchi — ADS1115 I2C ADC (joystick/rocker bridge on the shared I2C1 / Grove bus). + * SPDX-License-Identifier: MIT + * + * Read ONLY from the dashboard task (the OLED's task) so I2C1 keeps a single owner and needs no mutex. + * When HG_JOYSTICK is built, i2c1_bus.h drops the bus to 400 kHz (ADS1115 max). Every transfer is + * timeout-bounded, so a missing/half-wired module returns false instead of wedging the +0 task. + */ +#ifndef HG_ADS1115_H +#define HG_ADS1115_H + +#include +#include +#include "hardware/i2c.h" + +// Single-shot read of single-ended channel (0..3) vs GND. PGA ±4.096 V, 860 SPS. Returns false on any +// I2C error/timeout; on success *out = signed 16-bit conversion code. +bool ads1115_read(i2c_inst_t *i2c, uint8_t addr, uint8_t channel, int16_t *out); + +#endif /* HG_ADS1115_H */ diff --git a/firmware/c/src/cdc1_control.c b/firmware/c/src/cdc1_control.c index 161217e..68c44e0 100644 --- a/firmware/c/src/cdc1_control.c +++ b/firmware/c/src/cdc1_control.c @@ -38,6 +38,7 @@ #include "feedback.h" // M3.0: LED/buzzer HW-reconciliation test commands #include "hg_config.h" // M4.2: macro list ({"q":"macros"} / {"q":"macro"}) #include "dap_health.h" // DAP transfer/health witness ({"q":"status"} dap_xfers/dap_idle_ms) +#include "hg_input.h" // v1.2: button/joystick readback ({"q":"btn"} / {"q":"joy"}) // Build-discriminating tags compiled into the status reply so the RUNNING firmware proves its OWN // identity (closes the Gate-1 provenance gap). Mirror the CMake -D flags (PRIVATE on the target). @@ -86,7 +87,8 @@ static void write_status(uint8_t itf) { "\"stall_cfg\":%d,\"stall_us\":%u,\"prio\":%d," "\"dap_xfers\":%u,\"dap_idle_ms\":%u," "\"crashes\":%u,\"wd_armed\":%d,\"wd_gap\":%u,\"tud\":%u,\"page\":%d," - "\"urx_drop\":%u,\"urx_hw\":%u,\"utx_drop\":%u,\"frag\":%u}\n", + "\"urx_drop\":%u,\"urx_hw\":%u,\"utx_drop\":%u,\"frag\":%u," + "\"px\":%u,\"btn\":%d,\"joy\":%d}\n", HG_VERSION, (unsigned) xPortGetFreeHeapSize(), (unsigned) (time_us_64() / 1000000ull), @@ -96,7 +98,8 @@ static void write_status(uint8_t itf) { (unsigned) crash_box_count(), (int) wd_is_armed(), (unsigned) wd_max_gap_ms(), (unsigned) g_tud_checkin, (int) g_dash_screen, (unsigned) uart_bridge_drops(), (unsigned) uart_bridge_highwater(), - (unsigned) cdc_uart_tx_overflow(), (unsigned) s_partial); + (unsigned) cdc_uart_tx_overflow(), (unsigned) s_partial, + (unsigned) feedback_pixel_count(), hg_button_down(), hg_joy_ok()); if (len > 0) reply(itf, r); } @@ -311,6 +314,35 @@ static void handle_line(uint8_t itf, const char *line, int len) { #undef CLAMP8 char r[48]; snprintf(r, sizeof r, "{\"pixel\":[%d,%d,%d]}\n", rr, gg, bb); reply(itf, r); return; } + // v1.2 "Companion": drive the WHOLE WS2812 chain to one colour. {"q":"fill","r":..,"g":..,"b":..} + if (!strcmp(q, "fill")) { + int rr = 0, gg = 0, bb = 0; + get_int(line, tok, n, "r", &rr); get_int(line, tok, n, "g", &gg); get_int(line, tok, n, "b", &bb); + #define CLAMP8(v) ((uint8_t)((v) < 0 ? 0 : (v) > 255 ? 255 : (v))) + feedback_fill(CLAMP8(rr), CLAMP8(gg), CLAMP8(bb)); + #undef CLAMP8 + char r[56]; snprintf(r, sizeof r, "{\"fill\":[%d,%d,%d],\"px\":%d}\n", rr, gg, bb, feedback_pixel_count()); + reply(itf, r); return; + } + // v1.2: set an animated MOOD on the chain. {"q":"mood","n":0..6} (optional "i":0..255 intensity) + if (!strcmp(q, "mood")) { + int m = 0, inten = 200; get_int(line, tok, n, "n", &m); get_int(line, tok, n, "i", &inten); + if (inten < 0) inten = 0; + if (inten > 255) inten = 255; + feedback_mood(m, (uint8_t)inten); + char r[40]; snprintf(r, sizeof r, "{\"mood\":%d,\"i\":%d}\n", m, inten); reply(itf, r); return; + } + // v1.2 HIL readback: the on-device button (GP16). {"q":"btn"} -> held state + taps since boot. + if (!strcmp(q, "btn")) { + char r[48]; snprintf(r, sizeof r, "{\"down\":%d,\"presses\":%u}\n", hg_button_down(), (unsigned)hg_button_presses()); + reply(itf, r); return; + } + // v1.2 HIL readback: the joystick (ADS1115). {"q":"joy"} -> present? + raw X/Y + decoded direction. + if (!strcmp(q, "joy")) { + char r[72]; snprintf(r, sizeof r, "{\"ok\":%d,\"x\":%d,\"y\":%d,\"dir\":%d}\n", + hg_joy_ok(), hg_joy_x(), hg_joy_y(), hg_joy_dir()); + reply(itf, r); return; + } // M3 closeout HIL: feedback-layer readback — proves drive_feedback drove the buzzer/NeoPixel on events. if (!strcmp(q, "fb")) { uint32_t c = feedback_color(); diff --git a/firmware/c/src/feedback.c b/firmware/c/src/feedback.c index fc515dc..501b7e9 100644 --- a/firmware/c/src/feedback.c +++ b/firmware/c/src/feedback.c @@ -1,7 +1,14 @@ /* - * Hackagotchi — M3 user-feedback HAL (NeoPixel status LED + buzzer). SPDX-License-Identifier: MIT (see feedback.h) + * Hackagotchi — user-feedback HAL (WS2812 NeoPixel chain + buzzer). SPDX-License-Identifier: MIT (see feedback.h) + * + * v1.2 generalises the single onboard pixel to a CHAIN of HG_NEOPIXEL_COUNT WS2812s on GP12 (onboard = + * index 0, then an external strip soldered to the GP12 castellation). Default HG_NEOPIXEL_COUNT=1 keeps + * v1.1 behaviour exactly (one pixel, manual status colour). With a strip, callers can drive an animated + * MOOD (neopixel_anim.c). Everything is still latched cross-task and serviced from the +0 SD task + * (feedback_service) — the CPU only pushes words to the PIO, never blocking the DAP/USB path (R1). */ #include "feedback.h" +#include "neopixel_anim.h" #include "pico/stdlib.h" #include "hardware/pwm.h" @@ -10,37 +17,60 @@ #include "ws2812.pio.h" #define BUZZER 29u // D3 passive piezo (PWM) -#define NEOPIXEL_PIN 12u // XIAO onboard WS2812 data -#define NEOPIXEL_PWR 11u // XIAO NeoPixel power-enable (drive HIGH to power the pixel) +#define NEOPIXEL_PIN 12u // XIAO onboard WS2812 data (external strip chains off this line) +#define NEOPIXEL_PWR 11u // XIAO NeoPixel power-enable (onboard pixel only; power a strip from the 5V pad) #define NP_BRIGHT 0x30u // modest level so a single bright pixel isn't glaring +#ifndef HG_NEOPIXEL_COUNT +#define HG_NEOPIXEL_COUNT 1 // onboard pixel only (v1.1 default); set >1 when an external strip is soldered +#endif +#define NPX HG_NEOPIXEL_COUNT + // NeoPixel on pio1 (SWD owns pio0). Set once in feedback_init. static PIO s_np_pio = pio1; static uint s_np_sm = 0; static bool s_np_ok = false; -// Pending pixel request: bit31 = pending, low 24 = urgb (0x00GGRRBB). One 32-bit slot so a cross-task -// latch is a single aligned store; consumed (cleared) by feedback_service in the SD task. -static volatile uint32_t s_pixel_req = 0; +static uint32_t s_pixels[NPX]; // current colours (urgb GRB-packed) +static uint32_t s_shown[NPX]; // last frame pushed to the strip (push only on change) +static bool s_manual_dirty = true; + +// mode: MANUAL (feedback_pixel/fill/led) vs ANIM (feedback_mood) +enum { FB_MANUAL = 0, FB_ANIM = 1 }; +static volatile int s_mode = FB_MANUAL; +static volatile int s_mood = -1; +static volatile uint8_t s_intensity = 200u; -// Pending beep request: (hz << 16) | ms, 0 = none. Same single-slot atomic latch. +// Cross-task request latches (each a single aligned store; consumed by feedback_service on the SD task). +static volatile uint32_t s_pixel_req = 0; // bit31 pending | 24-bit colour -> pixel 0 (back-compat) +static volatile uint32_t s_fill_req = 0; // bit31 pending | 24-bit colour -> whole chain +static volatile uint32_t s_mood_req = 0; // bit31 pending | mood<<8 | intensity + +// Pending beep request: (hz << 16) | ms, 0 = none. static volatile uint32_t s_beep_req = 0; static bool s_beeping = false; static uint32_t s_beep_off_ms = 0; -// Readback for HIL ({"q":"fb"}): proves drive_feedback actually drove the HAL on each event, not just the -// recorder transition. s_beep_count ticks per beep STARTED; s_last_color is the last applied pixel colour. +// Readback for HIL ({"q":"fb"}). static volatile uint32_t s_beep_count = 0; static volatile uint32_t s_last_color = 0; uint32_t feedback_beep_count(void) { return s_beep_count; } uint32_t feedback_color(void) { return s_last_color; } // packed urgb: (g<<16)|(r<<8)|b bool feedback_is_beeping(void) { return s_beeping; } +int feedback_pixel_count(void){ return NPX; } +int feedback_mood_get(void) { return s_mode == FB_ANIM ? s_mood : -1; } static inline uint32_t urgb(uint8_t r, uint8_t g, uint8_t b) { // pack for the WS2812 (GRB) helper return ((uint32_t)r << 8) | ((uint32_t)g << 16) | (uint32_t)b; } -static inline void np_put(uint32_t u24) { // push one pixel; PIO clocks it in HW - if (s_np_ok) pio_sm_put_blocking(s_np_pio, s_np_sm, u24 << 8u); +static void np_show(void) { // push the whole chain; PIO clocks it in HW + if (!s_np_ok) return; + for (int i = 0; i < NPX; i++) pio_sm_put_blocking(s_np_pio, s_np_sm, s_pixels[i] << 8u); + for (int i = 0; i < NPX; i++) s_shown[i] = s_pixels[i]; +} +static bool np_changed(void) { + for (int i = 0; i < NPX; i++) if (s_pixels[i] != s_shown[i]) return true; + return false; } static void buzzer_on(uint16_t hz) { @@ -61,18 +91,28 @@ void feedback_init(void) { // Buzzer: PWM, silent. gpio_set_function(BUZZER, GPIO_FUNC_PWM); buzzer_off(); - // NeoPixel: power it, then bring up the PIO SM and blank it. + // NeoPixel: power the onboard pixel, then bring up the PIO SM and blank the chain. gpio_init(NEOPIXEL_PWR); gpio_set_dir(NEOPIXEL_PWR, GPIO_OUT); gpio_put(NEOPIXEL_PWR, 1); + for (int i = 0; i < NPX; i++) { s_pixels[i] = 0; s_shown[i] = 0; } if (pio_can_add_program(s_np_pio, &ws2812_program)) { uint off = pio_add_program(s_np_pio, &ws2812_program); ws2812_program_init(s_np_pio, s_np_sm, off, NEOPIXEL_PIN, 800000.0f, false); s_np_ok = true; - np_put(0); // off + np_show(); // off } } void feedback_pixel(uint8_t r, uint8_t g, uint8_t b) { - s_pixel_req = 0x80000000u | (urgb(r, g, b) & 0x00FFFFFFu); // pending + color + s_pixel_req = 0x80000000u | (urgb(r, g, b) & 0x00FFFFFFu); // pending + color (pixel 0) +} + +void feedback_fill(uint8_t r, uint8_t g, uint8_t b) { + s_fill_req = 0x80000000u | (urgb(r, g, b) & 0x00FFFFFFu); // pending + color (whole chain) +} + +void feedback_mood(int mood, uint8_t intensity) { + if (mood < 0) mood = 0; + s_mood_req = 0x80000000u | ((uint32_t)((unsigned)mood & 0xFFu) << 8) | (uint32_t)intensity; } // Convenience colour mapping for the {"q":"led"} test command + simple status use. red/green -> the @@ -93,9 +133,37 @@ void feedback_beep(uint16_t hz, uint16_t ms) { } void feedback_service(uint32_t now_ms) { + // ---- pixel / fill / mood latches (manual sets switch out of anim mode) ---- + uint32_t fr = s_fill_req; + if (fr & 0x80000000u) { + s_fill_req = 0; uint32_t c = fr & 0x00FFFFFFu; + for (int i = 0; i < NPX; i++) s_pixels[i] = c; + s_mode = FB_MANUAL; s_last_color = c; s_manual_dirty = true; + } uint32_t px = s_pixel_req; - if (px & 0x80000000u) { s_pixel_req = 0; uint32_t c = px & 0x00FFFFFFu; np_put(c); s_last_color = c; } + if (px & 0x80000000u) { + s_pixel_req = 0; uint32_t c = px & 0x00FFFFFFu; + s_pixels[0] = c; + s_mode = FB_MANUAL; s_last_color = c; s_manual_dirty = true; + } + uint32_t mr = s_mood_req; + if (mr & 0x80000000u) { + s_mood_req = 0; + s_mood = (int)((mr >> 8) & 0xFFu); + s_intensity = (uint8_t)(mr & 0xFFu); + s_mode = FB_ANIM; + } + + if (s_mode == FB_ANIM) { + np_anim_render(s_pixels, NPX, s_mood, now_ms, s_intensity); + s_last_color = s_pixels[0]; + if (np_changed()) np_show(); + } else if (s_manual_dirty) { + s_manual_dirty = false; + np_show(); + } + // ---- buzzer (edge-driven, unchanged from M3) ---- uint32_t req = s_beep_req; if (req) { s_beep_req = 0; diff --git a/firmware/c/src/feedback.h b/firmware/c/src/feedback.h index 2ec0540..3c422ae 100644 --- a/firmware/c/src/feedback.h +++ b/firmware/c/src/feedback.h @@ -22,6 +22,7 @@ #include #include +#include "neopixel_anim.h" // NP_MOOD_* for feedback_mood() // Bring up the LED GPIOs + the buzzer PWM slice (silent). Call once in main() before the scheduler. void feedback_init(void); @@ -38,9 +39,18 @@ void feedback_beep(uint16_t hz, uint16_t ms); void feedback_led(bool red, bool green); // Set the NeoPixel to an arbitrary RGB colour (0..255 each; keep modest — one pixel is bright). Safe -// to call from any task. +// to call from any task. This drives pixel 0 (the onboard pixel) for back-compat. void feedback_pixel(uint8_t r, uint8_t g, uint8_t b); +// --- v1.2 "Companion": external WS2812 chain (HG_NEOPIXEL_COUNT>1) + reactive mood animation --- +// Set the WHOLE chain to one colour (manual mode). Safe to call from any task. +void feedback_fill(uint8_t r, uint8_t g, uint8_t b); +// Switch the chain to an animated MOOD (NP_MOOD_*), serviced each SD-task tick. intensity 0..255 caps +// brightness. Stays in effect until another mood / a manual pixel set. Safe to call from any task. +void feedback_mood(int mood, uint8_t intensity); +int feedback_pixel_count(void); // number of WS2812 pixels in the chain (HG_NEOPIXEL_COUNT) +int feedback_mood_get(void); // current mood (NP_MOOD_*), or -1 if in manual mode + // HIL readback ({"q":"fb"}): prove the HAL actually fired on an event (not just the recorder transition). uint32_t feedback_beep_count(void); // beeps STARTED since boot (ticks once per edge-driven beep) uint32_t feedback_color(void); // last applied NeoPixel colour, packed urgb = (g<<16)|(r<<8)|b diff --git a/firmware/c/src/hackagotchi_dashboard.c b/firmware/c/src/hackagotchi_dashboard.c index de04857..b9ad791 100644 --- a/firmware/c/src/hackagotchi_dashboard.c +++ b/firmware/c/src/hackagotchi_dashboard.c @@ -27,6 +27,7 @@ #include "sd_gate.h" // rec_snapshot_t + dash_get_rec_snapshot() #include "hackagotchi_dashboard.h" #include "sprites.gen.h" // M-UI-2: generated 1-bit sprites (status-bar glyphs + ghost vitals states) +#include "hg_input.h" // v1.2: poll the joystick here (shares the OLED's I2C1 owner -> no mutex) #ifndef DASH_I2C_ADDR #define DASH_I2C_ADDR 0x3Cu @@ -604,6 +605,7 @@ void dashboard_task(void *ptr) { for (;;) { uint32_t now = (uint32_t)(time_us_64() / 1000ull); g_dash_counter++; + hg_joystick_poll(now); // v1.2: read the ADS1115 joystick (no-op unless HG_JOYSTICK) -> nav intents if (__atomic_exchange_n(&s_exorcise_req, 0, __ATOMIC_RELAXED)) s_exorcise_frames = 10; // M-UI-5: arm exorcism #if ADVERSARIAL_STALL_MS > 0 diff --git a/firmware/c/src/hg_input.c b/firmware/c/src/hg_input.c new file mode 100644 index 0000000..f9aa011 --- /dev/null +++ b/firmware/c/src/hg_input.c @@ -0,0 +1,90 @@ +/* Hackagotchi — on-device input (button + joystick) HW glue. SPDX-License-Identifier: MIT (see header) */ +#include "hg_input.h" +#include "input_logic.h" + +#ifndef HG_BUTTON +#define HG_BUTTON 0 +#endif +#ifndef HG_JOYSTICK +#define HG_JOYSTICK 0 +#endif + +#if HG_BUTTON || HG_JOYSTICK +#include "hackagotchi_dashboard.h" // dash_nav_step / dash_pet (atomic intents) +#endif +#if HG_BUTTON +#include "feedback.h" // chirp on a long-press pet +#endif +#ifndef HG_BUTTON_PIN +#define HG_BUTTON_PIN 16u // freed onboard GREEN-LED GPIO (RED = GP17, reserved for a 2nd button) +#endif + +#if HG_BUTTON || HG_JOYSTICK +#include +#endif +#if HG_JOYSTICK +#include "ads1115.h" +#include "i2c1_bus.h" +#ifndef HG_JOY_ADDR +#define HG_JOY_ADDR 0x48u // ADS1115 ADDR->GND +#endif +#ifndef HG_JOY_CH_X +#define HG_JOY_CH_X 0u // joystick X on AIN0 +#endif +#ifndef HG_JOY_CH_Y +#define HG_JOY_CH_Y 1u // joystick Y on AIN1 +#endif +// single-ended 3.3 V on the ±4.096 V FSR -> ~0..26400 codes; mid ~13200, deadzone ~6000. +#define HG_JOY_CENTER 13200 +#define HG_JOY_DEAD 6000 +#endif + +static volatile uint32_t s_presses = 0; +static volatile int s_btn_down = 0; +static volatile int s_joy_ok = 0, s_joy_x = 0, s_joy_y = 0, s_joy_dir = JOY_C; + +uint32_t hg_button_presses(void) { return s_presses; } +int hg_button_down(void) { return s_btn_down; } +int hg_joy_ok(void) { return s_joy_ok; } +int hg_joy_x(void) { return s_joy_x; } +int hg_joy_y(void) { return s_joy_y; } +int hg_joy_dir(void) { return s_joy_dir; } + +void hg_button_poll(uint32_t now_ms) { +#if HG_BUTTON + static int inited = 0; + static btn_t b; + if (!inited) { + gpio_init(HG_BUTTON_PIN); + gpio_set_dir(HG_BUTTON_PIN, GPIO_IN); + gpio_pull_up(HG_BUTTON_PIN); // active-low: pressed pulls the pin to GND + inited = 1; + } + int raw_down = gpio_get(HG_BUTTON_PIN) ? 0 : 1; + s_btn_down = raw_down; + int ev = btn_update(&b, raw_down, now_ms, 25u, 800u); + if (ev & BTN_EV_SHORT) { dash_nav_step(+1); s_presses++; } // tap -> next screen + if (ev & BTN_EV_LONG) { dash_pet(); feedback_beep(1760, 80); } // hold -> pet the cat +#else + (void)now_ms; +#endif +} + +void hg_joystick_poll(uint32_t now_ms) { + (void)now_ms; +#if HG_JOYSTICK + static joy_edge_t e; + int16_t vx = 0, vy = 0; + if (!ads1115_read(I2C1_BUS_INST, HG_JOY_ADDR, HG_JOY_CH_X, &vx) || + !ads1115_read(I2C1_BUS_INST, HG_JOY_ADDR, HG_JOY_CH_Y, &vy)) { + s_joy_ok = 0; // module absent/half-wired -> ignore + return; + } + s_joy_ok = 1; s_joy_x = vx; s_joy_y = vy; + int dir = joy_dir(vx, vy, HG_JOY_CENTER, HG_JOY_DEAD); + s_joy_dir = dir; + int edge = joy_edge(&e, dir); + if (edge == JOY_UP) dash_nav_step(-1); // flick up -> previous screen + if (edge == JOY_DOWN) dash_nav_step(+1); // flick down -> next screen +#endif +} diff --git a/firmware/c/src/hg_input.h b/firmware/c/src/hg_input.h new file mode 100644 index 0000000..505ec0b --- /dev/null +++ b/firmware/c/src/hg_input.h @@ -0,0 +1,26 @@ +/* + * Hackagotchi — on-device input (button + joystick) HW glue. SPDX-License-Identifier: MIT + * + * v1.2 "Companion". Button (XA008) on GP16 (a freed onboard-LED pin); joystick (XA011) via an ADS1115 on + * the Grove I2C bus. R1: the button is polled on the SD task (20 ms, GPIO only — no bus) and the joystick + * on the dashboard task (250 ms, same I2C owner as the OLED -> no mutex). Both lazy-init on first poll and + * are no-ops unless HG_BUTTON / HG_JOYSTICK is built, so the default image is unchanged. Nothing here runs + * at or above the DAP path. Inputs post atomic dashboard intents (dash_nav_step / dash_pet). + */ +#ifndef HG_INPUT_H +#define HG_INPUT_H + +#include + +void hg_button_poll(uint32_t now_ms); // call from the SD task each loop (~20 ms) +void hg_joystick_poll(uint32_t now_ms); // call from the dashboard task each loop (~250 ms) + +// CDC1 readback (HIL: prove the input actually reached firmware). +uint32_t hg_button_presses(void); // taps registered since boot +int hg_button_down(void); // 1 if currently held +int hg_joy_ok(void); // 1 if the last ADS1115 read succeeded (module present) +int hg_joy_x(void); // last raw X code +int hg_joy_y(void); // last raw Y code +int hg_joy_dir(void); // last decoded JOY_* direction + +#endif /* HG_INPUT_H */ diff --git a/firmware/c/src/i2c1_bus.h b/firmware/c/src/i2c1_bus.h index 87f42f2..fe7de5b 100644 --- a/firmware/c/src/i2c1_bus.h +++ b/firmware/c/src/i2c1_bus.h @@ -16,7 +16,14 @@ #define I2C1_BUS_INST i2c1 #define I2C1_BUS_SDA 6u #define I2C1_BUS_SCL 7u -#define I2C1_BUS_HZ 1000000u // Fast-mode Plus; was 400000u (Fast-mode) while shared with the RTC +// Fast-mode Plus (1 MHz) when the OLED is the sole device; drop to Fast-mode (400 kHz) when an ADS1115 +// joystick shares the Grove bus (ADS1115 max is 400 kHz). The OLED full-frame flush slows ~9 ms -> ~23 ms, +// which is fine on the +0 dashboard task. v1.0 era ran the whole bus at 400 kHz, soak-proven. +#if defined(HG_JOYSTICK) && (HG_JOYSTICK) +#define I2C1_BUS_HZ 400000u // Fast-mode (ADS1115 on the shared Grove bus) +#else +#define I2C1_BUS_HZ 1000000u // Fast-mode Plus; OLED is the sole device +#endif void i2c1_bus_init(void); // idempotent: i2c_init + gpio funcs + pullups (no mutex — single owner) diff --git a/firmware/c/src/input_logic.c b/firmware/c/src/input_logic.c new file mode 100644 index 0000000..ce5abab --- /dev/null +++ b/firmware/c/src/input_logic.c @@ -0,0 +1,38 @@ +/* Hackagotchi — on-device input logic (pure, host-tested). SPDX-License-Identifier: MIT (see header) */ +#include "input_logic.h" + +int btn_update(btn_t *b, int raw_down, uint32_t now_ms, uint32_t debounce_ms, uint32_t long_ms) { + int ev = BTN_EV_NONE; + uint8_t raw = raw_down ? 1u : 0u; + + if (raw != b->cand) { b->cand = raw; b->t_change = now_ms; } // candidate moved -> restart debounce + + if (b->cand != b->stable && (now_ms - b->t_change) >= debounce_ms) { + b->stable = b->cand; // commit the debounced level + if (b->stable) { ev |= BTN_EV_DOWN; b->t_press = now_ms; b->long_fired = 0; } + else { + ev |= BTN_EV_UP; + if (!b->long_fired) ev |= BTN_EV_SHORT; // released before the long threshold + } + } + + if (b->stable && !b->long_fired && (now_ms - b->t_press) >= long_ms) { + b->long_fired = 1; ev |= BTN_EV_LONG; // fire LONG once while still held + } + return ev; +} + +int joy_dir(int x, int y, int center, int dead) { + int dx = x - center, dy = y - center; + int ax = dx < 0 ? -dx : dx; + int ay = dy < 0 ? -dy : dy; + if (ay >= ax) { if (ay > dead) return dy > 0 ? JOY_UP : JOY_DOWN; } + else { if (ax > dead) return dx > 0 ? JOY_RIGHT : JOY_LEFT; } + return JOY_C; +} + +int joy_edge(joy_edge_t *e, int dir) { + int out = (dir != JOY_C && dir != e->last) ? dir : JOY_C; + e->last = dir; + return out; +} diff --git a/firmware/c/src/input_logic.h b/firmware/c/src/input_logic.h new file mode 100644 index 0000000..da6a8b6 --- /dev/null +++ b/firmware/c/src/input_logic.h @@ -0,0 +1,41 @@ +/* + * Hackagotchi — on-device input logic (PURE: button debounce + joystick decode). SPDX-License-Identifier: MIT + * + * No hardware deps — these are the testable FSMs behind hg_input.c. tests/m_ui/input_logic_test.c drives + * them with synthetic time/levels. hg_input.c wires them to GP16 (button) and the ADS1115 (joystick) and + * posts dashboard nav/pet intents. All callers run on the +0 tasks (never the DAP path). + */ +#ifndef HG_INPUT_LOGIC_H +#define HG_INPUT_LOGIC_H + +#include + +/* ---- button: debounce + press classification ---- */ +enum { BTN_EV_NONE = 0, BTN_EV_DOWN = 1, BTN_EV_UP = 2, BTN_EV_SHORT = 4, BTN_EV_LONG = 8 }; + +typedef struct { + uint8_t stable; // committed debounced level (1 = pressed) + uint8_t cand; // candidate level awaiting debounce + uint8_t long_fired; // long-press already emitted for the current hold + uint8_t _pad; + uint32_t t_change; // when `cand` last changed + uint32_t t_press; // when `stable` last went down +} btn_t; + +// Feed one debounced sample. raw_down = 1 when the button reads pressed. Returns an OR of BTN_EV_*. +// SHORT fires on release if the hold was shorter than long_ms; LONG fires once while still held. +int btn_update(btn_t *b, int raw_down, uint32_t now_ms, uint32_t debounce_ms, uint32_t long_ms); + +/* ---- joystick: dominant-axis direction with a deadzone ---- */ +enum { JOY_C = 0, JOY_UP, JOY_DOWN, JOY_LEFT, JOY_RIGHT }; + +// Decode raw ADC (x,y) about `center` with a `dead` zone. Sign convention: y above center = UP, +// x above center = RIGHT (flip the wiring or the channel if a stick reads inverted). +int joy_dir(int x, int y, int center, int dead); + +typedef struct { int last; } joy_edge_t; +// Returns `dir` only on a fresh transition from a different direction into a non-center dir; else JOY_C. +// So one physical flick = one event (no auto-repeat). +int joy_edge(joy_edge_t *e, int dir); + +#endif /* HG_INPUT_LOGIC_H */ diff --git a/firmware/c/src/neopixel_anim.c b/firmware/c/src/neopixel_anim.c new file mode 100644 index 0000000..59f48b8 --- /dev/null +++ b/firmware/c/src/neopixel_anim.c @@ -0,0 +1,73 @@ +/* Hackagotchi — NeoPixel mood animation (pure, host-tested). SPDX-License-Identifier: MIT (see header) */ +#include "neopixel_anim.h" + +static inline uint32_t urgb(uint8_t r, uint8_t g, uint8_t b) { + return ((uint32_t)r << 8) | ((uint32_t)g << 16) | (uint32_t)b; // WS2812 GRB packing +} +static inline uint8_t scale8(uint8_t v, uint8_t s) { + return (uint8_t)(((uint16_t)v * (uint16_t)s) / 255u); +} + +uint8_t np_tri(uint32_t now_ms, uint32_t period_ms) { + if (period_ms < 2u) return 0; + uint32_t p = now_ms % period_ms; + uint32_t h = period_ms / 2u; + uint32_t v = (p < h) ? (p * 255u / h) : (255u - (p - h) * 255u / h); + return (uint8_t)v; +} + +// breathing brightness with a floor, so a "calm" mood never goes fully dark +static uint8_t breathe(uint32_t now_ms, uint32_t period_ms, uint8_t floor) { + uint8_t t = np_tri(now_ms, period_ms); + uint16_t span = (uint16_t)(255u - floor); + return (uint8_t)(floor + (uint16_t)((uint16_t)t * span / 255u)); +} + +// classic 0..255 colour wheel -> rgb +static void wheel(uint8_t pos, uint8_t *r, uint8_t *g, uint8_t *b) { + pos = (uint8_t)(255u - pos); + if (pos < 85u) { *r = (uint8_t)(255u - pos * 3u); *g = 0; *b = (uint8_t)(pos * 3u); } + else if (pos < 170u) { pos = (uint8_t)(pos - 85u); *r = 0; *g = (uint8_t)(pos * 3u); *b = (uint8_t)(255u - pos * 3u); } + else { pos = (uint8_t)(pos - 170u); *r = (uint8_t)(pos * 3u); *g = (uint8_t)(255u - pos * 3u); *b = 0; } +} + +void np_anim_render(uint32_t *out, int count, int mood, uint32_t now_ms, uint8_t intensity) { + if (!out || count <= 0) return; + uint32_t span = (uint32_t)(count > 0 ? count : 1); + for (int i = 0; i < count; i++) { + uint8_t r = 0, g = 0, b = 0, br; + switch (mood) { + case NP_MOOD_IDLE: + br = breathe(now_ms + (uint32_t)i * 120u, 3400u, 40u); + r = 0; g = scale8(150u, br); b = scale8(200u, br); + break; + case NP_MOOD_RX: { + uint8_t t = np_tri(now_ms + (uint32_t)i * (700u / span), 700u); // pulse travels down the chain + r = 0; g = scale8(255u, t); b = scale8(30u, t); + break; + } + case NP_MOOD_WARN: + br = breathe(now_ms, 600u, 30u); + r = scale8(255u, br); g = scale8(95u, br); b = 0; + break; + case NP_MOOD_FAULT: + br = breathe(now_ms, 360u, 35u); + r = scale8(255u, br); g = 0; b = 0; + break; + case NP_MOOD_PET: + br = breathe(now_ms, 900u, 80u); + r = scale8(255u, br); g = scale8(40u, br); b = scale8(90u, br); + break; + case NP_MOOD_RAINBOW: { + uint8_t pos = (uint8_t)(((now_ms / 8u) + (uint32_t)i * (256u / span)) & 0xFFu); + wheel(pos, &r, &g, &b); + break; + } + case NP_MOOD_OFF: + default: + break; + } + r = scale8(r, intensity); g = scale8(g, intensity); b = scale8(b, intensity); + out[i] = urgb(r, g, b); + } +} diff --git a/firmware/c/src/neopixel_anim.h b/firmware/c/src/neopixel_anim.h new file mode 100644 index 0000000..4973581 --- /dev/null +++ b/firmware/c/src/neopixel_anim.h @@ -0,0 +1,32 @@ +/* + * Hackagotchi — NeoPixel mood animation (PURE: no hardware deps). SPDX-License-Identifier: MIT + * + * Renders a chain of WS2812 pixels for a "mood" at a given time. A deterministic function of + * (mood, now_ms, intensity, count) — no hardware, no globals — so tests/m_ui/neopixel_anim_test.c can + * assert exact behaviour with no bench. feedback.c calls this from the +0 SD task and pushes the result + * to the PIO (R1-safe). Output packing matches feedback.c's WS2812 helper: urgb = (g<<16)|(r<<8)|b. + */ +#ifndef HG_NEOPIXEL_ANIM_H +#define HG_NEOPIXEL_ANIM_H + +#include + +enum { + NP_MOOD_OFF = 0, // dark + NP_MOOD_IDLE, // calm teal breathing (logging, no recent traffic) + NP_MOOD_RX, // green pulse travelling down the chain (recent target traffic) + NP_MOOD_WARN, // amber pulse + NP_MOOD_FAULT, // red fast blink (wedge / SD fault) + NP_MOOD_PET, // warm pink pulse (companion interaction) + NP_MOOD_RAINBOW, // hue rotation (demo / "alive") + NP_MOOD__COUNT +}; + +// Render `count` pixels for `mood` at `now_ms`. `intensity` (0..255) is a global brightness cap. +// out[] entries are urgb-packed = (g<<16)|(r<<8)|b. No-op if out==NULL or count<=0. +void np_anim_render(uint32_t *out, int count, int mood, uint32_t now_ms, uint8_t intensity); + +// Triangle wave 0..255 over period_ms (exposed for tests). Returns 0 if period_ms < 2. +uint8_t np_tri(uint32_t now_ms, uint32_t period_ms); + +#endif /* HG_NEOPIXEL_ANIM_H */ diff --git a/firmware/c/src/sd_gate.c b/firmware/c/src/sd_gate.c index 039c499..c56917e 100644 --- a/firmware/c/src/sd_gate.c +++ b/firmware/c/src/sd_gate.c @@ -22,6 +22,7 @@ #include "feedback.h" // M3.0: non-blocking LED/buzzer service (serviced from this low-prio task) #include "hg_config.h" // M4.5: persisted baud + macros (KV config file on the SD card) #include "hackagotchi_dashboard.h" // M4.1: dash_hex_mode() — gate the per-loop raw-tail copy +#include "hg_input.h" // v1.2: poll the button here (20 ms, GPIO only — no bus, no mutex) TaskHandle_t sd_gate_taskhandle; @@ -273,11 +274,25 @@ static void drive_feedback(void) { if (err && !last_err) { feedback_beep(500, 700); s_faults++; } // SD write/full fault -> buzz if (st.hits > last_hits) feedback_beep(2600, 70); // trigger-term hit -> blip } +#if defined(HG_NEOPIXEL_COUNT) && (HG_NEOPIXEL_COUNT > 1) + // v1.2 "Companion": an external WS2812 chain shows a reactive MOOD instead of a single status colour. + // fault > recent-traffic > logging > off. RX latches for 1.5 s so the green pulse outlives a burst. + static uint32_t active_until = 0, last_rx_total = 0; + uint32_t now = (uint32_t)(time_us_64() / 1000ull); + if (st.rx_total > last_rx_total) { active_until = now + 1500u; last_rx_total = st.rx_total; } + int mood = (st.wedge || err) ? NP_MOOD_FAULT + : (now < active_until) ? NP_MOOD_RX + : (st.logging) ? NP_MOOD_IDLE + : NP_MOOD_OFF; + feedback_mood(mood, 200); + (void)last_color; +#else uint32_t color = (st.wedge || err) ? 0x400000u : (st.logging ? 0x001000u : 0u); // red / dim green / off if (color != last_color) { feedback_pixel((uint8_t)(color >> 16), (uint8_t)(color >> 8), (uint8_t)color); last_color = color; } +#endif last_wedge = st.wedge; last_err = err; last_hits = st.hits; last_rx = rx_seen; first = false; } @@ -311,6 +326,7 @@ void sd_gate_task(void *ptr) { if (s_cfg_save_req) { do_config_save(); s_cfg_save_req = false; } // M4.5 persist on change publish_snapshot(); // M3: hand the dashboard + CDC1 a consistent copy of recorder state drive_feedback(); // M3.3: map recorder events -> buzzer + NeoPixel (edge-detected) + hg_button_poll(now); // v1.2: debounce the GP16 button (no-op unless HG_BUTTON) -> nav/pet feedback_service(now); // M3.0: apply the latched beep/pixel + buzzer-off, off the hot path vTaskDelay(pdMS_TO_TICKS(20)); } diff --git a/firmware/c/tests/m_ui/M_UI_RESULTS.md b/firmware/c/tests/m_ui/M_UI_RESULTS.md new file mode 100644 index 0000000..1419b49 --- /dev/null +++ b/firmware/c/tests/m_ui/M_UI_RESULTS.md @@ -0,0 +1,37 @@ +# M-UI / v1.2 "Companion" — test results + +## Host unit tests (no hardware — run in CI) + +| Test | Command | Result | +|---|---|---| +| Sprite blit | `cc -I src -I src/ssd1306 tests/m_ui/blit_test.c -o /tmp/blit && /tmp/blit` | (M-UI baseline) | +| NeoPixel mood renderer | `cc -I src tests/m_ui/neopixel_anim_test.c src/neopixel_anim.c -o /tmp/npa && /tmp/npa` | **PASS** (2026-06-22) | +| Button + joystick logic | `cc -I src tests/m_ui/input_logic_test.c src/input_logic.c -o /tmp/inp && /tmp/inp` | **PASS** (2026-06-22) | + +`neopixel_anim_test` asserts off==dark, intensity caps, per-mood channels, the breathing wave, and a +rainbow that actually differs across the chain. `input_logic_test` asserts debounce, short-vs-long +classification, bounce rejection, joystick deadzone/dominant-axis decode, and one-event-per-flick edges. + +## Build + static-analysis gate (2026-06-22) + +| Build | Result | +|---|---| +| Default (`HG_NEOPIXEL_COUNT=1`, button/joystick OFF) | builds clean; `analyze.sh` **PASS**; DAP/USB hot path still SRAM-pinned (HG_PIN_DAP intact) | +| Companion (`HG_NEOPIXEL_COUNT=5 HG_BUTTON=ON HG_JOYSTICK=ON`) | builds clean; `analyze.sh` **PASS**; new symbols present; `ver=1.2.0-companion` | + +Pre-existing `-Wformat-truncation` notes in `hackagotchi_dashboard.c` / `sd_gate.c` are unchanged from +v1.1 and are not gate failures (`analyze.sh` gates `-Wanalyzer-*` + the two pristine files only). + +## HIL (hardware-in-the-loop) — PENDING the soldered build + +`tests/m_ui/companion_hil.py` — REQUIRED checks (status carries px/btn/joy; mood/fill/btn/joy commands +answer) + interactive button/joystick liveness. Hardware-blind-safe (exit 2 with no probe). + +- **2026-06-22, against the bench unit running `1.1.0-pin-dh` (v1.1):** correctly **FAIL** — the v1.1 + image returns `"err":"unknown"` for the new commands and has no px/btn/joy status fields. This is the + intended "feed it a known-bad input, watch it go red" check; it confirms the test is not a silent pass. +- **Against `1.2.0-companion`:** not yet run — flash the companion image (`HG_NEOPIXEL_COUNT=N + HG_BUTTON=ON HG_JOYSTICK=ON ./build_fork.sh`, then `picotool load -x`) and re-run. Bar: + `COMPANION SURFACE: PASS`. +- **Gate 1 coexist soak on the companion image:** not yet run — re-prove **0 stalls** with the strip + driving + the ADS1115 on the 400 kHz bus before shipping any feature ON. diff --git a/firmware/c/tests/m_ui/companion_hil.py b/firmware/c/tests/m_ui/companion_hil.py new file mode 100644 index 0000000..3f438b3 --- /dev/null +++ b/firmware/c/tests/m_ui/companion_hil.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +companion_hil.py — v1.2 "Companion" HIL: prove the NeoPixel-chain / button / joystick control surface is +in the running image and reacts. Two tiers: + + REQUIRED (machine-checked, can fail): {"q":"status"} carries px/btn/joy; mood/fill/btn/joy commands all + return well-formed JSON with the expected keys. This catches a build that silently dropped the surface. + + LIVENESS (interactive, informational): you're prompted to press the button and waggle the stick; if the + readback moves, that's a real end-to-end witness (HW wired + feature ON). If nothing moves it WARNs + (feature OFF in this image, or not soldered yet) rather than failing — so the gate stays honest. + +Build the image with the features on first, e.g.: + HG_NEOPIXEL_COUNT=5 HG_BUTTON=ON HG_JOYSTICK=ON ./build_fork.sh (then flash) + + .venv/bin/python tests/m_ui/companion_hil.py # bar: "COMPANION SURFACE: PASS" + .venv/bin/python tests/m_ui/companion_hil.py --no-interactive # skip the press/waggle prompts + +Hardware-blind-safe: exits 2 if no control port is found (can't run yet), 0 = pass, 1 = fail. +""" +import os, sys, time, json +import serial +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from hil_ports import find_ctrl + +INTERACTIVE = "--no-interactive" not in sys.argv +CTRL = find_ctrl() +if not CTRL: + print("companion_hil: no CDC1 control port found — can't run (wire the probe).") + sys.exit(2) + + +def q(query, wait=0.8): + s = serial.Serial(CTRL, 115200, timeout=0.3); s.dtr = True; time.sleep(0.15); s.reset_input_buffer() + s.write((query + "\n").encode()); s.flush() + t0 = time.time(); buf = b"" + while time.time() - t0 < wait: + buf += s.read(256) + s.close() + for l in reversed([x for x in buf.decode(errors="replace").splitlines() if x.strip().startswith("{")]): + try: + return json.loads(l) + except Exception: + continue + return {} + + +fails, warns = [], [] +def need(cond, msg): + print((" ok " if cond else " FAIL") + " " + msg) + if not cond: + fails.append(msg) +def note(cond, msg): + print((" ok " if cond else " warn") + " " + msg) + if not cond: + warns.append(msg) + + +print(f"== companion HIL on {CTRL} ==") + +st = q('{"q":"status"}') +need(st.get("fw") == "Hackagotchi", f'status identifies firmware (ver={st.get("ver")})') +need("px" in st and "btn" in st and "joy" in st, f'status carries px/btn/joy ({{px:{st.get("px")}, btn:{st.get("btn")}, joy:{st.get("joy")}}})') +px = int(st.get("px", 0)) +need(px >= 1, f'pixel count sane (px={px})') + +# --- NeoPixel chain: mood + fill --- +m = q('{"q":"mood","n":4,"i":180}') # FAULT mood +need(m.get("mood") == 4 and m.get("i") == 180, f'mood command echoes ({m})') +f = q('{"q":"fill","r":0,"g":40,"b":0}') # dim green over the whole chain +need(f.get("px") == px and isinstance(f.get("fill"), list), f'fill command echoes whole chain ({f})') +q('{"q":"mood","n":1}') # leave it idle-breathing + +# --- button + joystick readback shape --- +b = q('{"q":"btn"}') +need("down" in b and "presses" in b, f'btn readback well-formed ({b})') +j = q('{"q":"joy"}') +need(all(k in j for k in ("ok", "x", "y", "dir")), f'joy readback well-formed ({j})') +note(int(j.get("ok", 0)) == 1, f'joystick (ADS1115) present on the bus (ok={j.get("ok")}) — warn if not wired/feature off') + +# --- LIVENESS (interactive) --- +if INTERACTIVE: + p0 = int(q('{"q":"btn"}').get("presses", 0)) + print(" >>> PRESS the button now (you have 8 s) ...") + moved = False + t0 = time.time() + while time.time() - t0 < 8: + if int(q('{"q":"btn"}').get("presses", 0)) > p0: + moved = True; break + time.sleep(0.4) + note(moved, "button press registered (taps incremented)") + + print(" >>> WAGGLE the joystick up/down now (you have 8 s) ...") + seen = set() + t0 = time.time() + while time.time() - t0 < 8: + d = int(q('{"q":"joy"}').get("dir", 0)) + if d: + seen.add(d) + if len(seen) >= 1 and time.time() - t0 > 2: + break + time.sleep(0.3) + note(len(seen) >= 1, f"joystick direction registered (dirs seen={sorted(seen)})") + +print() +if fails: + print(f"COMPANION SURFACE: FAIL ({len(fails)} required check(s)) — {fails}") + sys.exit(1) +print(f"COMPANION SURFACE: PASS" + (f" ({len(warns)} liveness warning(s): {warns})" if warns else "")) +sys.exit(0) diff --git a/firmware/c/tests/m_ui/input_logic_test.c b/firmware/c/tests/m_ui/input_logic_test.c new file mode 100644 index 0000000..77e96d2 --- /dev/null +++ b/firmware/c/tests/m_ui/input_logic_test.c @@ -0,0 +1,61 @@ +/* Host unit test for the pure button + joystick logic. SPDX-License-Identifier: MIT + * cc -I src tests/m_ui/input_logic_test.c src/input_logic.c -o /tmp/inp && /tmp/inp + * No hardware. Verifies debounce, short vs long classification, bounce rejection, joystick deadzone + + * dominant-axis decode, and one-event-per-flick edge detection. + */ +#include +#include +#include "input_logic.h" + +static void test_button(void) { + btn_t b = {0}; + // press; not stable until the debounce window elapses + assert(btn_update(&b, 1, 0u, 25u, 800u) == BTN_EV_NONE); + assert(btn_update(&b, 1, 10u, 25u, 800u) == BTN_EV_NONE); + int ev = btn_update(&b, 1, 30u, 25u, 800u); // 30 >= 25 -> commit DOWN + assert((ev & BTN_EV_DOWN) && !(ev & BTN_EV_LONG)); + // release before the long threshold -> SHORT + assert(btn_update(&b, 0, 100u, 25u, 800u) == BTN_EV_NONE); // candidate up, not yet committed + ev = btn_update(&b, 0, 130u, 25u, 800u); // commit UP + assert((ev & BTN_EV_UP) && (ev & BTN_EV_SHORT) && !(ev & BTN_EV_LONG)); + + // long press: LONG fires once while held, and release after a long hold yields no SHORT + btn_t c = {0}; + btn_update(&c, 1, 0u, 25u, 800u); + ev = btn_update(&c, 1, 30u, 25u, 800u); assert(ev & BTN_EV_DOWN); + ev = btn_update(&c, 1, 900u, 25u, 800u); assert(ev & BTN_EV_LONG); // held past 800 (t_press=30) + ev = btn_update(&c, 1, 1000u, 25u, 800u); assert(!(ev & BTN_EV_LONG)); // only once + btn_update(&c, 0, 1100u, 25u, 800u); + ev = btn_update(&c, 0, 1130u, 25u, 800u); + assert((ev & BTN_EV_UP) && !(ev & BTN_EV_SHORT)); + + // bounce rejection: a blip shorter than the debounce window never commits a press + btn_t d = {0}; + btn_update(&d, 1, 0u, 25u, 800u); + assert(btn_update(&d, 0, 10u, 25u, 800u) == BTN_EV_NONE); + assert(btn_update(&d, 0, 40u, 25u, 800u) == BTN_EV_NONE); // settled up; never went down +} + +static void test_joy(void) { + const int C = 13200, D = 6000; + assert(joy_dir(C, C, C, D) == JOY_C); + assert(joy_dir(C, C + 7000, C, D) == JOY_UP); + assert(joy_dir(C, C - 7000, C, D) == JOY_DOWN); + assert(joy_dir(C + 7000, C, C, D) == JOY_RIGHT); + assert(joy_dir(C - 7000, C, C, D) == JOY_LEFT); + assert(joy_dir(C + 3000, C + 1000, C, D) == JOY_C); // inside the deadzone + + joy_edge_t e = {0}; + assert(joy_edge(&e, JOY_C) == JOY_C); + assert(joy_edge(&e, JOY_UP) == JOY_UP); // fresh C->UP fires + assert(joy_edge(&e, JOY_UP) == JOY_C); // held -> no repeat + assert(joy_edge(&e, JOY_C) == JOY_C); + assert(joy_edge(&e, JOY_DOWN) == JOY_DOWN); +} + +int main(void) { + test_button(); + test_joy(); + printf("input_logic_test: OK\n"); + return 0; +} diff --git a/firmware/c/tests/m_ui/neopixel_anim_test.c b/firmware/c/tests/m_ui/neopixel_anim_test.c new file mode 100644 index 0000000..012b84d --- /dev/null +++ b/firmware/c/tests/m_ui/neopixel_anim_test.c @@ -0,0 +1,57 @@ +/* Host unit test for the pure NeoPixel mood renderer. SPDX-License-Identifier: MIT + * cc -I src tests/m_ui/neopixel_anim_test.c src/neopixel_anim.c -o /tmp/npa && /tmp/npa + * No hardware. Asserts the invariants a soak can't see: off==dark, intensity caps, per-mood channels, + * the breathing wave, and that a rainbow actually differs across the chain. + */ +#include +#include +#include "neopixel_anim.h" + +static uint8_t R(uint32_t u) { return (uint8_t)((u >> 8) & 0xFFu); } +static uint8_t G(uint32_t u) { return (uint8_t)((u >> 16) & 0xFFu); } +static uint8_t B(uint32_t u) { return (uint8_t)(u & 0xFFu); } + +int main(void) { + uint32_t px[8]; + + // OFF -> every pixel dark, at any time/intensity + np_anim_render(px, 8, NP_MOOD_OFF, 1234u, 255u); + for (int i = 0; i < 8; i++) assert(px[i] == 0); + + // intensity 0 -> dark regardless of mood (the global brightness cap works) + np_anim_render(px, 8, NP_MOOD_RX, 500u, 0u); + for (int i = 0; i < 8; i++) assert(px[i] == 0); + + // FAULT -> red only, and never fully dark (floor), across a full blink period + int saw_red = 0; + for (uint32_t t = 0; t < 360u; t += 20u) { + np_anim_render(px, 1, NP_MOOD_FAULT, t, 255u); + if (R(px[0]) > 0) saw_red = 1; + assert(G(px[0]) == 0 && B(px[0]) == 0); // pure red + } + assert(saw_red); + + // IDLE -> teal: red channel always 0, blue present at some point + int saw_blue = 0; + for (uint32_t t = 0; t < 3400u; t += 50u) { + np_anim_render(px, 1, NP_MOOD_IDLE, t, 255u); + assert(R(px[0]) == 0); + if (B(px[0]) > 0) saw_blue = 1; + } + assert(saw_blue); + + // triangle wave bounds + assert(np_tri(0u, 1000u) == 0); + assert(np_tri(500u, 1000u) == 255); + assert(np_tri(250u, 1000u) > 100 && np_tri(250u, 1000u) < 160); + assert(np_tri(7u, 1u) == 0); // degenerate period + + // RAINBOW -> the chain is not a single colour + np_anim_render(px, 8, NP_MOOD_RAINBOW, 0u, 255u); + int differ = 0; + for (int i = 1; i < 8; i++) if (px[i] != px[0]) differ = 1; + assert(differ); + + printf("neopixel_anim_test: OK\n"); + return 0; +}