Skip to content

fix(nrf,esp): add missing shared sources/stubs so pristine CI builds link#170

Merged
RobertDaleSmith merged 78 commits into
mainfrom
fix/nrf-missing-elo-vagabond-source
Jul 8, 2026
Merged

fix(nrf,esp): add missing shared sources/stubs so pristine CI builds link#170
RobertDaleSmith merged 78 commits into
mainfrom
fix/nrf-missing-elo-vagabond-source

Conversation

@RobertDaleSmith

@RobertDaleSmith RobertDaleSmith commented Jul 8, 2026

Copy link
Copy Markdown
Member

Problem

A cold-cache (pristine) build fails across nRF and ESP because several symbols referenced unconditionally were never wired into those build systems. main CI had been masking them via cached build state; a fresh branch surfaces them deterministically.

Platform Undefined symbol Root cause Broke
nRF elo_vagabond_interface elo/elo_vagabond.c was RP2040-only usb2usb_..._max3421
nRF sinput_ble_register (unconditional, bthid_registry.c:52) generic/sinput_ble.c never in nRF build all BT targets
nRF platform_adc_* (XIAO battery path) platform_gpio_nrf.c only in controller_btusb block bt2usb on XIAO
ESP flash_get_write_count diag counter implemented for RP2040+nRF, never stubbed for ESP bt2usb_esp32s3, controller_btusb_..._esp32s3

Fix

nRF (nrf/CMakeLists.txt):

  • Add elo/elo_vagabond.c to both USB-host blocks.
  • Add generic/sinput_ble.c to all three bthid_registry blocks.
  • Promote platform_gpio_nrf.c to the base SRCS list (with flash_nrf.c/button_nrf.c/ws2812_nrf.c); drop the redundant per-block copy.

ESP (esp/main/flash_esp32.c):

  • Add the g_flash_write_count counter + flash_get_write_count() getter, mirroring flash_nrf.c, incremented in flash_save_now.

Verification

Built locally with the NCS and ESP-IDF toolchains — all 7 affected CI targets produce firmware:

nRF: btusb2usb_feather · bt2usb_feather · bt2usb_seeed_xiao · controller_btusb_feather · usb2usb_feather_max3421
ESP: bt2usb_xiao_esp32s3 · controller_btusb_feather_esp32s3

RPi targets are untouched (changes are confined to nrf/ and esp/).

Not related to #168

The mouse-sign fix (#168) compiled clean; these are pre-existing cross-platform build gaps, no longer cache-masked, surfaced by the same CI run.

Brings controller_btusb up on the bare Seeed XIAO nRF52840 (BLE peripheral +
central, USB device out) and fixes a chain of board-specific bugs found
bringing it up on real hardware.

- Makefile: add controller_btusb_seeed_xiao_nrf52840 build/flash/monitor targets.

- nrf/boards/xiao_ble.overlay: enable i2c0 on the exposed D4/D5 pads
  (P0.04/P0.05) with internal bias-pull-up. Needed for JoyWing/seesaw; the
  pull-ups stop a floating bus from wedging the TWIM at boot.

- nrf/CMakeLists.txt: make the boot-time OLED probe (OLED_I2C_DISPLAY) opt-in
  per board — only the Feather (FeatherWing OLED + external pull-ups) enables
  it. On a bare XIAO the SSD1306 probe on a floating bus hung early boot and
  dropped the app into the bootloader.

- app.c (bt_central_post_init): suppress the central's auto-scan when BT input
  is disabled. scan_suppressed defaults false, so the host kept BLE scanning on
  power-up even with bt_input off, and concurrent scanning starved the
  peripheral advertiser on the SoftDevice controller -> nothing to pair with.
  Also adds a gated JP_RECOVERY_WIPE_ON_BOOT factory-reset path for recovering
  a board soft-bricked by bad NVS config.

- platform_gpio_nrf.c: reject unusable GPIO (out of range, and the P0.00/P0.01
  LFXO crystal pins) instead of configuring them. A bad custom-pad pin saved to
  NVS faulted early boot every time and survived reflash (UF2 doesn't erase NVS)
  -> soft-brick. Now skipped defensively.

- cdc_commands.c (CAPS.GET): include input sources that only appear in routes
  (e.g. ble_central) in inputs[], so the payload is self-consistent and host
  tools mapping routes->inputs don't hit a missing element.

- tusb_config_nrf.h: grow CDC TX FIFO 1024 -> 8192 (RX 256 -> 512). The web
  config fires ~9 queries at once and cdc_task emits all responses in one pass;
  this board's CAPS.GET (~880B, with BLE sections) overflowed the 1024B FIFO and
  cdc_data_write() silently dropped MODE.LIST and other responses, hanging the
  USB Device page on "Loading...". 8K holds a full page-load burst.
The BLE peripheral on nRF (XIAO/Feather) reported advertising but was never
discoverable. Three stacked nRF-specific bugs, each fixed:

- Standard BLE mode never enabled random static addressing, so BTstack
  advertised with public address type. ble_output.c now sets
  GAP_RANDOM_ADDRESS_TYPE_STATIC for standard mode on nRF (non-nRF transports
  like CYW43 have a real public address and are left unchanged).

- BD_ADDR was 00:00:00:00:00:00. The Nordic chipset path only set the chipset
  and relied on a ZEPHYR_READ_STATIC_ADDRESS that returns zeros, and
  READ_BD_ADDR (empty public addr) clobbered it. bt_transport_nrf.c now uses
  the FICR factory static-random address for the Nordic path and ignores the
  zero reads instead of overwriting with them.

- The standard adv payload was 32 bytes (flags+name+UUID+appearance), 1 over
  the 31-byte legacy limit, so the controller silently rejected Set Advertising
  Data. Moved the complete name "JoypadOS Controller" into the scan response;
  the primary packet now carries flags+UUID+appearance (11 bytes).

Verified on-air with a BLE scanner: advertises as "JoypadOS Controller" with
the HID service UUID. These are shared BLE-output fixes, so they also benefit
nRF peripheral builds on Feather.
…tton

On the XIAO, button_nrf.c hardcodes the board "user button" to P0.03 (D1),
the same GPIO used for a gamepad button via the pad config. Presses fed both
the pad input (correct, e.g. B2) and the user-button driver, whose
double-click cycles the USB output mode and triple-click resets to SInput —
so wiring a button to D1 caused spurious mode changes.

The XIAO has no dedicated user button, so add DISABLE_USER_BUTTON: when set,
button_nrf.c's button_init() no-ops and no button events fire. Enabled for
controller_btusb on xiao_ble in nrf/CMakeLists.txt, freeing every pad GPIO for
controller input. The Feather keeps its real user switch (P1.02). Mode
switching / BT scan / bond-clear remain available over CDC.
JoyWing (seesaw I2C) is a Feather-class accessory that registers its own
top-level input interface, so it showed up in the device-info input list on
the bare XIAO ("Pad + JoyWing + ble_central") even with nothing attached —
inconsistent, since pad sources/buttons aren't otherwise enumerated there.
Gate SENSOR_JOYWING to the Feather only; the XIAO uses GPIO pad input. Device
info is now "Pad + ble_central".
On a combined central+peripheral build (controller_btusb), a host connecting
to our BLE gamepad output raised the same HCI_SUBEVENT_LE_CONNECTION_COMPLETE
the host/central manager uses for outgoing controller connections. With no
role check it stored the incoming peripheral link in hid_state.connections —
inflating the host connection count (BT.STATUS "Connected (2)"), tagging it
with the central's stale pending address (00:00:00:00:00:00 bonds), and
kicking off spurious central-side SM pairing. Disabling BT host didn't clear
it because that only stops central scanning, not the peripheral.

Ignore connections with role != central in btstack_host; incoming peripheral
links belong to ble_output. Central (input controller) connections are
unaffected, and bt2usb (central-only) never sees peripheral roles. Verified:
BT.STATUS now reports connections:0 / devices:[] with BT host disabled.
pad_config_flash's pin_ok() capped valid pins at 29 (RP2040 GPIO range), so any
saved pad config using an nRF P1.x pin (raw index >=32, e.g. P1.12-P1.15 = 44-47)
or P0.30/31 failed pad_config_flash_valid() on load — pad_config_load_runtime()
discarded the whole record and fell back to defaults. Symptom: a D-pad mapped to
P1.12-P1.15 saved fine but reverted to default on every reload/reboot.

Make the cap platform-specific via PAD_MAX_GPIO (nRF52840 -> 47, ESP32-S3 -> 48,
RP2040 -> 29). Verified: PAD.CONFIG.SET dpad=44-47 now survives reboot
(source=flash, buttons[0..3]=[44,45,46,47]).
The nRF52840 base devicetree leaves gpio1 (the entire P1 GPIO port)
status="disabled", and the XIAO board never enables it. The XIAO breaks out
D6-D10 = P1.11-P1.15 (pad pins 43-47), so any button/D-pad mapped to a P1.x pin
silently did nothing: device_is_ready(gpio1) returns false, platform_gpio_nrf
skips configuring the pin, and reads return garbage (floating) — which looked
like the D-pad directions were swapped. P0.x pins worked because gpio0 is
enabled. Enable gpio1 in the XIAO overlay so the P1 pads are usable. Verified:
a button on P1.12 (pin 44) now registers correctly.
…meout

On battery, power the device down (nRF System OFF, ~1 uA) instead of staying
connected/advertising, so a bonded host can't auto-reconnect and hog the link
and the battery isn't drained while unused. Wakes on a button press (full
reboot -> advertise -> reconnect).

Triggers (battery only — never while USB-powered):
- Deliberate host disconnect (HCI reason 0x13 remote-user / 0x16 local) ->
  sleep. A dropped link (0x08 supervision timeout) keeps advertising to
  reconnect, as before.
- Idle timeout: 5 min with no input (button held or stick off-center, tracked
  in router_submit_input) -> sleep.

Wake source = the B1 button. platform_deep_sleep() configures the pin's SENSE
to its PRESSED level using the pad config's active_high flag (active-high:
pull-down + sense-high; active-low: pull-up + sense-low) — getting this wrong
made the idle-level pin wake the SoC immediately.

Platform HAL: new platform_deep_sleep(pin, active_high), platform_usb_powered(),
and platform_last_reset_reason() (raw nRF RESETREAS, surfaced in INFO's "reset"
field — handy for diagnosing why it woke). nRF implements System OFF via
sys_poweroff() (CONFIG_POWEROFF=y) + GPIO sense; RP2040/ESP32 stub to no-op.
controller_btusb wires the wake pin from the B1 config and runs the idle check
in app_task. Verified on XIAO hardware: sleeps on deliberate disconnect and on
idle, wakes on B1, stays asleep otherwise.
The BLE Battery Service was hardcoded to battery_service_server_init(100) and
never updated, so hosts always saw 100%. Read the actual VBAT and push it.

- platform_battery_millivolts(): XIAO nRF52840 reads battery on AIN7 (P0.31)
  through the P0.14-gated ~1/3 divider (P0.14 held LOW; driving it HIGH risks
  the P0.31 3.6V limit). SAADC gain 1/6 + 0.6V ref → VBAT = raw*3600/4095*2.96.
  Returns -1 on boards without battery sense (Feather/RP2040/ESP32 stub).
  Enables CONFIG_ADC/CONFIG_ADC_NRFX_SAADC.
- ble_output: a BTstack-thread timer samples VBAT every 60s (first at 2s),
  maps mV→percent on a rough LiPo curve, and calls
  battery_service_server_set_battery_value(). No-op when mV < 0.
- INFO now reports "battery_mv" for diagnostics.

Verified on XIAO: INFO battery_mv=3788 (~3.79V) → ~51%, vs the old fixed 100%.
Note: the divider draws ~3uA while P0.14 is held low (incl. deep sleep) — a
minor future optimization could gate it off before System OFF.
…port

SInput already carries charge_level/plug_status, populated by sinput_mode from
event->battery_level/battery_charging — but those only ever came from a
connected INPUT controller (DS4/DS5 etc.). On controller_btusb the device IS
the controller, so the fields stayed 0 and hosts saw no battery.

Feed the device's own battery into the output event:
- router: router_set_onboard_battery(percent, charging); the router stamps it
  into any output state that has no input-device battery, so both the USB
  SInput report and (mirrored) the BLE Battery Service reflect the device.
  Input-device battery still takes priority.
- controller_btusb app_task samples platform_battery_millivolts() + VBUS every
  30s and pushes percent/charging to the router (single ADC reader, main
  thread). No-op where there's no battery sense.
- ble_output BAS now mirrors router_onboard_battery_percent() instead of
  reading the ADC itself (removes a second-thread ADC access).
- shared battery_percent_from_mv() moved to core/battery.h.

Verified on XIAO over USB HID: SInput input report plug_status=2 (charging),
charge_level=76% — was fixed 0/unknown before.
…annel

The battery divider (1M||510k ≈ 337kOhm Thevenin) is a high-impedance source;
the SAADC sample cap can't settle in the 10us acquisition window used for the
low-impedance analog sticks, so AIN7 under-read ~5% (a full 4.2V cell read
~4.0V → ~79%). Use 40us acquisition for channels >=4 (sense inputs like the
battery), keeping AIN0-3 (sticks) at 10us for fast polling.

Measured on XIAO: ~4000mV → ~4060mV after the change.
After the acquisition-time fix the XIAO still read ~4.06V for a known-full
LP103454 on the charger — the nRF SAADC internal 0.6V reference has a few %
gain tolerance and reads low (~3%). Apply a single-point gain trim (factor
1.033, calibrated against the 4.2V full state) in platform_battery_millivolts.
Now reads ~4.20V → 100% full. XIAO_VBAT_TRIM_NUM/DEN are overridable for an
exact per-unit BAT+ multimeter calibration.
Replace the VBUS-only charging proxy with the XIAO's real charge-status line.
platform_battery_charging() reads P0.17 (BQ25100 CHG / charge-LED line: LOW
while charging, HIGH when done/idle), consulted only while on USB power. When
the charger reports complete on power, controller_btusb pins the level to 100%
(the curve may read ~96% at termination), so SInput plug_status reports
CHARGING(2) / CHARGED(3) / ON_BATTERY(4) accurately. INFO exposes "chg".
Verified on XIAO: chg=1 while actively charging a partly-drained cell.
The nRF retains GPIO output state (and the WS2812 latches its color) through
System OFF, so the "connected" blue LED (XIAO P0.06, active-low) could stay lit
while the device was actually asleep. It was inconsistent — the first sleep
went dark, later ones didn't — because it depended on whether leds_task
happened to repaint "off" before power-down.

Add neopixel_off() (synchronous LED off for both the XIAO discrete LEDs and the
Feather WS2812) and call it as the very last step in platform_deep_sleep with
irq_lock() held, so the leds_task thread can't repaint the connected color
between the LED-off write and sys_poweroff(). LED now goes off on every sleep.
Read the XIAO Sense's onboard 6-axis IMU (accel + gyro) and feed it into the
SInput motion report. The whole downstream path already existed (input_event
accel[]/gyro[]/has_motion, sinput_mode emits them) — this adds the source.

- nrf/src/imu_nrf.c: direct I2C register driver for the LSM6DS3TR-C (0x6a/0x6b),
  104 Hz / ±4 g / ±2000 dps. Raw 16-bit output already matches the int16 range
  SInput wants, so no conversion. Runs after USB init so a wedged bus can't
  block enumeration. No-op on boards without the IMU node.
  IMPORTANT: drives the P1.08 power-enable high itself (high drive) — the
  regulator-fixed node does NOT reliably hold the IMU rail on, which is why the
  IMU intermittently failed to ACK. That was the whole root cause.
- router: router_set_onboard_motion()/_get() — the router stamps the device's
  own motion into output states that have no input-device motion (mirrors the
  onboard-battery pattern), so USB SInput carries accel/gyro.
- overlay: i2c0 → the IMU's internal pins P0.07/P0.27, LSM6DS3TR-C node.
- INFO now reports "imu":[ax,ay,az,gx,gy,gz] (or false) for diagnostics.

Verified on hardware: at rest accel Z ~8140 (1 g), gyro ~0; tracks on motion.
When a USB data host is connected (VBUS + tud_mounted), the BLE peripheral
yields: any BT link is dropped and advertising stops, so the controller is used
over USB only. When no USB host is present (battery or dumb charger), the normal
BT logic runs (advertise, connect, deep-sleep on disconnect, B1 wake).

A 500ms BTstack-thread timer enforces it (handles USB plugged/unplugged while
running); the disconnect handler stays off instead of re-advertising/sleeping
while a USB host is present; advertising state is tracked (set_adv) to toggle
idempotently. Gated on VBUS && tud_mounted so a stale mount flag after unplug
can't wrongly suppress BT. Verified on XIAO: unplug→advertise+connect, plug
in→disconnect+USB works.
The onboard LED used usb_gamepad_active() (tud_mounted && mode != CDC) to decide
"connected", so while a USB data host was present but in CDC/config mode it read
as nothing-connected and ran the BT-searching flash (white/blue) even though BT
was suppressed by the USB-dominance rule. Use the same VBUS+mounted test the
dominance logic uses, so USB-host-present shows a steady USB-mode color.
Adds router_set_motion_remap()/router_get_motion_remap() plus an IMU.MAP CDC
command so a rotated/flipped onboard IMU (e.g. a XIAO Sense mounted 180° in a
controller) can be corrected at the source. The remap (per output axis: signed
source axis, default identity {1,2,3}) is applied to accel and gyro together in
router_set_onboard_motion(), so every consumer — the SInput report, Steam/SDL,
joypad-web — gets mounting-corrected data. Tunable live over CDC; bake the final
values in as the default once dialed in.
Brings the full SInput protocol to the BLE device output so buttons, gyro/accel
and battery reach SDL/Steam wirelessly — for builds (e.g. a tucked-away XIAO
Sense) where USB isn't practical.

- Factor sinput_mode.c into transport-neutral builders reused by USB and BLE:
  sinput_report_build_from_event() (input report 1 from a router event),
  sinput_build_feature_response()/sinput_feature_response_take() (feature
  report 2), sinput_output_received()/sinput_get_rumble_lr() (output report 3).
  New sinput_mode.h exposes them; SINPUT_DESCRIPTORS_NO_USB lets BLE reuse the
  report structs/descriptor without tusb.h (which clashes with btstack_hid.h).
- ble_output.c: BLE_MODE_SINPUT serves the SInput report map, streams input
  report 1 (continuous, IMU included), answers the feature handshake (input
  report 2) and receives commands via output report 3 (haptic -> rumble, LEDs,
  features request). SInput VID/PID via the DIS PnP ID so SDL's driver matches.
- ble_gamepad.gatt: add a (report 3, output) characteristic for SInput's command
  channel; standard mode ignores it. Report storage bumped 6 -> 8.
- controller_btusb defaults to BLE_MODE_SINPUT (no USB/CDC needed to select it).

Builds clean for the XIAO nRF52840 target; BLE behavior pending on-hardware
verification with SDL/Steam.
Adds flash_get_write_count() (flashw) and sinput_get_feature_count() (featw),
surfaced in the CDC INFO response. Used to prove the SInput report stream isn't
stalling on-device (no periodic flash writes, no feature-response flapping) —
handy for future IMU/stream debugging. Counters live in flash.c + flash_nrf.c
(nRF NVS path) and sinput_mode.c.
The XIAO Sense is mounted on the controller back, components toward the ground
(chip upside-down), USB-C toward the top. In that placement the LSM6 maps to the
controller as chip X=forward/back, Y=left/right, Z=down, so the raw axes need
swap X<->Y + invert Z to reach SInput's canonical device frame (report X=left,
Y=forward, Z=up). Set via router_set_motion_remap(2,1,-3) at imu_init so it's
applied to accel+gyro before the report is built — USB SInput and BLE SInput
alike — and still overridable live over the IMU.MAP CDC command. Verified in
joypad-web (SDL decode): pitch/roll un-swapped, roll ~0 at flat.
Nose-up read as negative pitch. On this mount the LSM6's X axis points toward
the controller's back, so SInput's forward (report.y) = -chip X, not +chip X.
Remap default is now router_set_motion_remap(2, -1, -3): report.x=+chipY (left),
report.y=-chipX (forward), report.z=-chipZ (up). Consistent for accel and gyro
(same physical axis), so it's spec-correct, not a display tweak.
Bank-right read as negative roll. The LSM6's Y axis points toward the
controller's right, so SInput's left (report.x) = -chip Y. Final mount remap is
router_set_motion_remap(-2, -1, -3): report.x=-chipY (left), report.y=-chipX
(forward), report.z=-chipZ (up). Pitch/roll/yaw now all read correct-handed;
applies to USB + BLE SInput.
Adds platform_reboot_ota() and an OTA command so a tucked-away nRF52840 can be
updated wirelessly:
- nRF: platform_reboot_ota() sets GPREGRET=0xA8 (Adafruit DFU_MAGIC_OTA_APP_RESET)
  and resets — the bootloader comes up in BLE OTA DFU (advertises "AdafruitDFU").
  RP2040/ESP32 fall back to their USB bootloaders.
- New "OTA" CDC command (cmd_ota → PENDING_OTA → platform_reboot_ota), deferred
  like BOOTSEL. Works over the BLE NUS too, since the NUS tunnels this command
  protocol — so the reboot-to-DFU can be triggered entirely over the air.
- Makefile `ota-zip` target packages the nRF build (zephyr.hex) into a DFU .zip
  via adafruit-nrfutil for pushing with nRF Connect.

Flow: send OTA over BLE -> device reboots to AdafruitDFU -> push joypad_ota.zip
from nRF Connect. First OTA should be validated with USB recovery available.
…ver BLE

Appends a keyboard(6) + consumer(7) + mouse(8) tail to the SInput BLE report
map (gamepad stays at IDs 1/2/3), matching the USB SInput composite. Two goals:
- USB parity — keyboard/mouse collections available over BLE too.
- Unblock web-config: macOS exclusively claims a *pure* HID game controller,
  which locks Chrome's Web Bluetooth out of the device (NUS "Unsupported
  device") while it's paired as a gamepad. A composite that also presents a
  keyboard is not claimed exclusively (as the old STANDARD composite wasn't),
  so web-config's NUS can connect alongside macOS gamepad use.

SDL/Steam keys on the gamepad collection + VID/PID (2E8A:10C6), both unchanged,
so SInput recognition is preserved. Map assembled at init by concatenating the
tail onto sinput_report_descriptor; GATT gains (6,in)(6,out)(7,in)(8,in) report
characteristics; hid_report_storage bumped 8->12.

HW-unverified — validate: (1) Steam still sees the controller, (2) web-config
NUS connects while macOS-paired, (3) gyro still streams in joypad-web.
The CDC/NUS command protocol was binary-only (0xAA sync + CRC framing), which
tools like COMrade — whose serial send is plain text — can't produce. Add a
text path alongside the binary one: when the RX state machine sees '{' instead
of the sync byte, it accumulates a newline-delimited JSON object and dispatches
it as a CMD. Responses to a text command are sent back as plain JSON + CRLF
(not a binary frame) so a serial terminal / COMrade shows a readable line.

Binary framing is untouched (web-config, joypad-live keep working); text_mode is
per-command and reset on any binary frame. Enables driving the device's BLE NUS
with `send_serial {"cmd":"INFO"}` etc. — a USB-less dev channel.
…p detection

Content/demo feature (disabled by default): a DualSense connected over
Bluetooth screams from its built-in speaker when dropped — free-fall is
detected from the IMU mid-air (~100ms), the scream plays while falling,
and landing is classified into a random "hurt" quip (hard impact) or
"nice catch" quip (soft deceleration).

Build (opt-in): DS5_DROP_SCREAM=1 make bt2usb_pico_w
Default builds are unaffected: all feature code is compiled out behind
CONFIG_DS5_DROP_SCREAM, and no BTstack library sources are modified.

How it works:
- Audio rides extended output report 0x36 on the Classic-HID L2CAP
  interrupt channel: sub-packets for audio-control, SetStateData
  (keeps LED/rumble alive during playback), haptic PCM (silence), and
  a 200-byte Opus frame; CRC32 (0xA2 seed) over the 398-byte report.
- Opus frames are pre-encoded OFFLINE (tools/ds5-scream/encode.py:
  48kHz stereo, 160kbps hard-CBR, 10ms frames, 16/15 pitch pre-comp)
  and baked into flash — no on-device encoder.
- Frames stream at the controller's real drain rate of 10.667ms/packet
  (64-byte haptic buffer @ 3kHz stereo), sent via direct l2cap_send()
  with per-packet can-send-now pacing and retry-don't-drop.
- The interrupt-channel CID is observed through the public hci_dump
  interface (ENABLE_LOG_BTSTACK_EVENTS + silent tap): BTstack delivers
  L2CAP_EVENT_CHANNEL_OPENED only to the owning service, but feeds it
  through hci_dump first — no library patch needed.
- Mic streaming is NOT enabled (0x11 mask 0xFE) and input parsing is
  gated to exact 78-byte reports: mic-audio input reports otherwise
  parse as garbage gamepad input (phantom buttons, junk accel).
- While a DS5 is connected, discovery is silenced (inquiry/BLE scan
  windows starve the ACL link and stutter the stream); generic-BLE-HID
  auto-connect is skipped entirely in content builds.
- Bench trigger on the mute button (debounced): scream → impact quip
  → stop, for testing without throwing the controller.

Unconditional fixes that fell out of hardware debugging:
- btstack_host_start_timed_scan() now refuses while a Classic
  connection setup is in progress: inquiry over the LMP encryption
  exchange starves it (~30s stall, disconnect reason 0x22) and the
  controller never finishes connecting.
- btstack_host_delete_all_bonds() now also clears the JPLC
  last-connected record (RAM + flash TLV); previously a deleted BLE
  bond resurrected on every boot and burned the radio in doomed 10s
  gap_connect() loops.

Reference: SundayMoments/DS5_Bridge, egormanga/SAxense, awalol/DS5Dongle.
…vice while macOS holds HID)

Self-contained macOS BLE control for Joypad devices. Uses CoreBluetooth
retrieveConnectedPeripherals (via pyobjc) to reach the device's NUS even while
it's connected to macOS as a HID gamepad — which Web Bluetooth (blocklisted) and
bleak (advertising-only) both cannot do. Speaks the binary 0xAA command protocol
(info/cmd/ota). Verified: live INFO round-trip over BLE with no USB/browser.
Runtime-disarmed by default even in content builds: the mute button no
longer plays anything — it toggles the drop-scream on/off (700ms
debounced), with a brief lightbar cue (green = armed, red = disarmed).
Audio now only ever plays from an actual detected fall.

Also fixes the lightbar sticking on a stale color (white after
playback): restores now resend the player slot color explicitly instead
of relying on the cached-value feedback path.
…ay commentary

Realtime function calling: set_lightbar / rumble / scream / switch_persona
tools declared in-session; function_call items collected at response.done,
executed over CDC (VOICE.FX), outputs returned, multi-round loop lets it
narrate what it did. Persona handover rebuilds the session with the new
voice after a goodbye, then the newcomer introduces itself.

--commentary MIN: every MIN minutes of active play (fresh button activity
in VOICE.CTX), one unprompted in-character observation about play style
from the button statistics.
…ish two JoypadOS boards, so an explicit pin must never fall back to scanning
…mes ~200ms after mic-enable clears; parsed as input they became random button/stick/mute garbage during THINK (scrambled host + talk state machine). Classify-and-discard for 600ms after LISTEN ends.
…ain-loop pass time

Telemetry during choppy playback: ring full (21-22/24), L2CAP busy=0, but
frames leaving every 15-21ms instead of 10.67 — the loop got heavier (senses,
pumps) and the voice engine sent exactly one frame per pass, playing audio at
2/3 speed with gaps. Both drain paths (host ring + flash clips) now send up
to 3 frames per call when behind schedule; the controller's intake queue
absorbs the bursts and average cadence holds regardless of loop timing.
…read noise (28 plug/unplug ghosts/session, each an unprompted reaction fighting reply audio); battery out of context until offset verified against a raw dump
…uring playback — measured 89/197 sends over budget (worst 103ms) from response traffic + log TX contention
…tart (>=45 frames or end-marker flush) — absorbs 100ms-class host write stalls instead of chasing them; bridge sends end marker after each reply
…ack now drains the ring before stopping (hard stop wiped the buffered tail and clipped every reply); ring-empty + end-pending stops instantly instead of the 600ms underrun wait
…resses context — stale ring entries (PS reconnects, handling bumps) were narrated as fresh presses; an empty statement beats a vacuum the model fills with fiction
…presses SINCE the last report (32-slot ring, lifetime counter + watermark); repeated asks get 'nothing new' instead of a re-served stale list the model embellishes
…ve any drain window and spray phantom buttons at the HID host during THINK; mic-signature frames are never parsed as input (1/4096 legit-report cost, invisible at 250Hz)
…truth framing made the model volunteer press lists in unrelated replies
bt2usb had a SInput input driver for USB (sinput_host.c) but none for BLE, so a
SInput controller (controller_btusb on nRF/Pico W) over BLE fell through to the
generic gamepad fallback, which can't parse SInput's custom report ID 1 — it
connected but delivered no input.

Two fixes, verified end-to-end (nRF → BLE SInput → bt2usb → USB → Switch):

- sinput_ble.c: BLE bthid driver matching JoypadOS by VID/PID (2E8A:10C6) or
  advertised name, registered above the generic fallback. Parses report ID 1
  into the router — buttons, both sticks, both analog triggers, and IMU.

- btstack_host.c: raise the LE ATT MTU to 247. The default 23 caps GATT
  notifications at 20 bytes, so a 64-byte SInput report was never delivered
  (device connected, zero reports flowed — looked like a pairing failure). The
  MouthPad worked only because its reports fit in 20 bytes.
…m ban

Firmware notifies 'falling' at free-fall start; the bridge generates the
GPT reaction DURING the descent and plays it ~0.35s after the impact event
— reaction lands moments after the thud instead of seconds later. Shake
jerk threshold 8000->2600 (per-report deltas at 250Hz are far smaller than
assumed; shake never fired). Reactions banned from the scream tool (model
was double-screaming after the real one).
…er COMPANION_HARNESS_PLAN)

The companion is now a gameplay advisor, not just a friend: four new
session tools — set_game (declares the current game, persists to memory,
and background-caches a full text walkthrough), search_guide (windowed
keyword retrieval over the cached guide), web_search (Bing HTML, stdlib —
DDG captchas bots; base64 redirect decoding), read_page (fetch -> clean
text with keyword-centered excerpts). Advisor doctrine in the baseline:
guide-first lookups, short actionable next-step answers, never spoil
beyond the ask. Current game + guide-cache status ride every context
injection. Verified live: search -> fextralife -> 68KB Elden Ring guide
cached -> retrieval returns relevant sections.
…eFAQs, load_guide tool, walkthrough-quality scoring

GameFAQs 403s plain fetchers and curl (Cloudflare interstitial); a driven
Safari tab passes it — safari_fetch grabs the source and closes the tab.
fetch_any falls back to Safari for gamefaqs/gamespot. Guide prefetch now
scores candidates by walkthrough-keyword density x size (biggest-text-wins
cached a wiki index; retrieval missed and the model searched in circles).
extract_guide_text pulls the faqtext/pre block. New load_guide tool lets
the model install a specific guide URL it found via web_search. Verified:
user-supplied RE Director's Cut FAQ -> 258KB real walkthrough -> retrieval
returns lockpick/item-level answers.
…laces keyword retrieval

ask_guide sends the ENTIRE cached guide (up to 400KB) plus the player's
situation to gpt-4o-mini in one call (~1 cent) and returns extracted next
steps. Keyword-window retrieval scattered across a 258KB walkthrough gave
the companion mediocre fragments it improvised around — wrong answers to
easy questions. Verified against the user's real stuck-point (RE1 post-
Kenneth): oracle returns the exact correct route (ammo from Kenneth,
main hall, behind the staircase). search_guide remains as offline
fallback.
…ness + concrete delivery

The model's paraphrase dropped load-bearing details (asking about Wesker
WITH BARRY selects the Jill scenario and its staircase trigger) — the
whisper transcript of the player's utterance now rides along on every
ask_guide. Oracle instructed to select character/scenario from mentioned
companions/items and to quote the concrete trigger, not summarize; Dusty
instructed never to be vaguer than the source. Oracle answers logged.
Verified against the user's verbatim stuck-point: returns the guide's
exact staircase-trigger passage.
…floor

'Resident Evil' vs 'Resident Evil Director's Cut' created parallel guide
slugs — the fresh name background-cached 435KB of franchise lore while
the real walkthrough sat under the older slug ('the guide has nothing').
Name-token subset matching reuses existing guides (digit/numeral tokens
keep sequels distinct); prefetch refuses to cache pages without real
walkthrough-language density; ask_guide resolves through the fuzzy match
too.
…nal, digest, multi-source)

Per-game libraries (guides/<slug>/): manifest.json (canonical name,
aliases, platform, source provenance), guide.txt + guide2.txt, digest.md,
journal.log.

Identity: existing libraries win before any LLM call (spoken aliases
accumulate in the manifest); otherwise one canonicalization call with
exact-title priority (bare "Resident Evil" resolved to Village without
it); dir-slug and legacy-flat fuzzy matching migrate old caches.

Journal (MaterialHandler pillar): every guide exchange appends
{when, situation, advice}; the oracle receives the tail and infers
where the player is — verified: "did the staircase thing, now what?"
returns the NEXT step (art-room statue puzzle) with zero restating.

Digest: one-time structured compression (areas in order, items, bosses,
scenario branches) built at cache time, rides every turn as silent data.

Multi-source: prefetch expands GameFAQs FAQ-index pages into individual
guides, keeps the two best-scoring sources with provenance; oracle
cross-checks, preferring the primary.
…ipping/scratching

Controller consumes 480 samples/10.667ms = true 45kHz; the 2.0x upsample
played replies 6.7% slow and pitch-dropped. Now proper 1.875x linear
interpolation. Near-full-scale TTS also clipped the tiny speaker — each
response peak-normalizes to 68% full scale.
@RobertDaleSmith
RobertDaleSmith force-pushed the fix/nrf-missing-elo-vagabond-source branch from cb6ddf4 to d57b4db Compare July 8, 2026 19:09
@RobertDaleSmith RobertDaleSmith changed the title fix(nrf): add missing elo_vagabond.c to nRF build sources fix(nrf): add missing elo_vagabond.c + sinput_ble.c to nRF build sources Jul 8, 2026
@RobertDaleSmith
RobertDaleSmith force-pushed the fix/nrf-missing-elo-vagabond-source branch from d57b4db to 9985d30 Compare July 8, 2026 19:17
@RobertDaleSmith RobertDaleSmith changed the title fix(nrf): add missing elo_vagabond.c + sinput_ble.c to nRF build sources fix(nrf): add missing shared sources to nRF build (elo, sinput_ble, platform_gpio) Jul 8, 2026
…link

A cold-cache (pristine) build failed across nRF and ESP because several
symbols referenced unconditionally were never wired into those build
systems. main CI had been masking them via cached build state; a fresh
branch surfaced them.

nRF (nrf/CMakeLists.txt):
- hid_registry.c -> elo_vagabond_interface: elo/elo_vagabond.c was RP2040-
  only. Added to both USB-host source blocks. (usb2usb_..._max3421)
- bthid_registry.c calls sinput_ble_register() unconditionally, but
  generic/sinput_ble.c was never in the nRF build. Added to all three bthid
  blocks. (all BT targets)
- platform_nrf.c XIAO battery path calls platform_adc_* from
  platform_gpio_nrf.c, which was only in the controller_btusb block.
  Promoted it to the base SRCS list; dropped the redundant per-block copy.
  (bt2usb on XIAO)

ESP (esp/main/flash_esp32.c):
- cdc_commands.c -> flash_get_write_count: the diag counter (flash.h) was
  implemented for RP2040 and nRF but never stubbed for ESP. Added the
  counter + getter mirroring flash_nrf.c, incremented in flash_save_now.
  (bt2usb_esp32s3, controller_btusb_feather_esp32s3)

Verified locally with the NCS and ESP-IDF toolchains: all 5 nRF and both
ESP CI targets build to firmware.
@RobertDaleSmith
RobertDaleSmith force-pushed the fix/nrf-missing-elo-vagabond-source branch from 9985d30 to fec9a50 Compare July 8, 2026 19:28
@RobertDaleSmith RobertDaleSmith changed the title fix(nrf): add missing shared sources to nRF build (elo, sinput_ble, platform_gpio) fix(nrf,esp): add missing shared sources/stubs so pristine CI builds link Jul 8, 2026
@RobertDaleSmith
RobertDaleSmith merged commit f5c4f0c into main Jul 8, 2026
66 checks passed
@RobertDaleSmith
RobertDaleSmith deleted the fix/nrf-missing-elo-vagabond-source branch July 8, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant