Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!--
Thanks for contributing to Hackagotchi! 🐾
This template maps to CONTRIBUTING.md §7. Fill in what applies; delete what doesn't.
The one rule that governs most reviews: the probe must never stall (R1). See §2.
-->

## What & why

<!-- What does this change do, and why? Link any issue: "Closes #123". -->

## Type of change

<!-- Tick all that apply. -->

- [ ] `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?

<!--
Reviewers look here FIRST. The priority order is:
UART bridge / watchdog > USB (TUD) > DAP > dashboard + SD writer
"On or above the DAP path" = the bridge cdc_task, any TUD callback, or DAP priority itself.
If your change is host-only / docs-only / lands entirely on the +0 dashboard/SD task, say so and tick "No".
-->

- [ ] **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 proves "builds + analyze.sh passes" — NOT that the gates ran. HIL gates are attested by hand.
Paste host-test output and/or the HIL *_hil.py PASS lines. If you can't run a HIL test, say "needs bench: <which>".
Never report a HIL claim as proven unless you (or a recorded *_RESULTS.md entry) ran it on hardware.
-->

- [ ] **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: <which>`:

```
…paste the *_hil.py PASS lines, or the GATE_RESULTS.md / *_RESULTS.md entry…
```
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,11 @@ Send `{"q":"<cmd>", ...}\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 |
Expand Down
20 changes: 20 additions & 0 deletions firmware/c/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions firmware/c/build_fork.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
Expand All @@ -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"

Expand All @@ -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

Expand Down
31 changes: 31 additions & 0 deletions firmware/c/src/ads1115.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* Hackagotchi — ADS1115 I2C ADC driver. SPDX-License-Identifier: MIT (see header) */
#include "ads1115.h"
#include <pico/stdlib.h>

#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, &reg, 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;
}
20 changes: 20 additions & 0 deletions firmware/c/src/ads1115.h
Original file line number Diff line number Diff line change
@@ -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 <stdbool.h>
#include <stdint.h>
#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 */
36 changes: 34 additions & 2 deletions firmware/c/src/cdc1_control.c
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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),
Expand All @@ -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);
}

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading