diff --git a/docs/migration-notes.md b/docs/migration-notes.md index 5a2d246..989feec 100644 --- a/docs/migration-notes.md +++ b/docs/migration-notes.md @@ -27,12 +27,12 @@ implementation. |---|---|---:|---|---| | Game buttons ×4 | main.py:371 | GPIO18/21/20/19, pull-up, active-low | `gpio-keys` node `buttons`, IRQ + workqueue debounce | Ported | | IR RX | main.py:200 | GPIO16, pull-up, active-low (TSOP4856) | alias `ir-rx`, GPIO IRQ + cycle timestamps | Ported | -| IR TX | main.py:223 | GPIO17, active-high (PN2222A → IR LED) | alias `ir-tx`, software 56 kHz carrier | Ported (SW carrier) | +| IR TX | main.py:223 | GPIO17, active-high (PN2222A → IR LED) | alias `ir-tx`, hardware PWM 56 kHz carrier (bit-bang fallback) | Ported | | Piezo | main.py:44 | GPIO15, active-high (NPN) | alias `piezo`, GPIO toggle | Ported | | RGB LED | lib/rgb.py:17-19 | GPIO22/26/27, PWM 100 Hz, active-high | `pwm-leds` aliases `rgb-red/green/blue` | Ported | | Battery sense | main.py:67 | GPIO28 / ADC ch2 | `zephyr_user` io-channel, `adc_dt_spec` | Ported | -| Heartbeat LED | main.py:255 | onboard "LED" | alias `heartbeat-led` (GPIO25 on plain Pico) | Ported | -| BLE UART | lib/ble_uart | radio | custom NUS GATT service (`CONFIG_APP_BLE`) | Ported (Pico W/ESP32 only) | +| Heartbeat LED | main.py:255 | GPIO14 (external) | alias `heartbeat-led` | Ported — moved off GPIO25 (CYW43 WL_CS on Pico W) | +| BLE UART | lib/ble_uart | radio | custom NUS GATT service (`CONFIG_APP_BLE`) | Ported — auto-on for Pico W | ## Module map @@ -59,7 +59,7 @@ implementation. | button → `olt_sent_ir` | button callback → IR TX request | | `ble_rx_handler` | NUS RX callback → parse → IR TX / RGB | | IR RX pin IRQ + timer | GPIO IRQ timestamps + frame-timeout `k_timer` → decode work | -| `ir_tx.transmit` ISR/PIO | dedicated high-priority IR TX thread, `k_busy_wait` carrier | +| `ir_tx.transmit` ISR/PIO | non-blocking `k_timer` symbol state machine + hardware PWM (RP2040); cooperative thread + bit-bang fallback | ## API mapping @@ -86,28 +86,49 @@ implementation. indexed into `{0,1,4,5,7,10,15,17,20,25,30,35,40,50,75,100}`; teams `RED/BLUE/YELLOW/GREEN`. - Commands: NewGame `0x8305E8`, Explode `0x830BE8`, TestShot `0x082400`, SensorTest `0x831500`. -## Open questions / TODO (hardware assumptions) +## Resolved hardware questions (second pass) -- **Battery divider ratio.** main.py:87 divides the measured voltage by 2 before the - alkaline capacity lookup, implying a 1:2 divider on VSYS/GPIO28. Confirm the real PCB - divider and update `BATTERY_DIVIDER_*` in `app_config.h`. ADC reference is assumed 3.3 V. -- **RP2040 PWM channel numbers.** The overlay assumes GPIO22/26/27 map to PWM channels - 6/10/11 (slice·2+chan). Verify against the board's `&pwm` once Zephyr is available. -- **IR TX carrier.** Implemented as a software-bit-banged 56 kHz carrier for portability. - Recommended upgrade: RP2040 PIO or ESP32 RMT (the MicroPython code used both). The - software carrier monopolises a high-priority thread for ~30 ms per frame. -- **IR TX duty.** 40 % duty (ir_tx/olt.py) is approximated by the SW carrier; not tunable - without PWM/PIO. -- **Heartbeat LED on Pico W.** The Pico W onboard LED is behind the CYW43 chip, not a plain - GPIO. The overlay uses GPIO25 (valid on the non-W Pico). Adjust for Pico W if needed. -- **ESP32 / STM32 pin maps.** The `esp32_devkitc_wroom` and `nucleo_f401re` overlays are - stubs with TODO pin assignments — verify before use. -- **BLE board support.** BLE requires a radio: build the Pico W or ESP32 target with - `-DEXTRA_CONF_FILE=overlay-ble.conf`. +- **Battery divider.** No external resistor divider: the 2-cell AA pack (~3 V, within the + 3.3 V ADC range) is sampled directly and divided by the cell count (2) in firmware to feed + the single-cell alkaline model. `BATTERY_DIVIDER_NUM/DEN = 1/2` in `app_config.h` now + documents this as a cell count, not a resistor ratio. +- **RP2040 PWM channels.** Confirmed by the slice math (channel = slice·2 + A/B, slice = + (gpio/2)%8): GPIO22→6, GPIO26→10, GPIO27→11. Documented in `rpi_pico.overlay`. +- **Heartbeat LED / Pico W hazard.** GPIO25 is the CYW43439 SPI chip-select (WL_CS) on the + Pico W — toggling it would corrupt WiFi/BLE. Heartbeat moved to GPIO14 (external LED), safe + on every RP2040 variant. None of the other app pins (15-22, 26-28) collide with the CYW43 + pins (23/24/25/29). +- **BLE on Pico W.** Enabled out of the box via `boards/rpi_pico_rp2040_w.conf` + (`CONFIG_APP_BLE=y`). `bt_set_name()` needs `CONFIG_BT_DEVICE_NAME_DYNAMIC`, now selected by + `APP_BLE` in `Kconfig`. + +## Remaining / accepted limitations + +- **IR TX carrier.** `src/ir_tx.c` selects a backend from the `ir-tx` devicetree node: + - On RP2040 (primary) the node is a **hardware PWM** channel and TX is fully + **non-blocking / real-time**: the 56 kHz / 40 % carrier comes from the PWM peripheral and + a one-shot `k_timer` drives a symbol state machine. Each expiry (system-clock ISR) just + flips the duty (40 %↔0 %) and arms the next symbol — the CPU is free between symbols, so + TX never busy-waits and runs concurrently with IR RX and BLE. Needs + `CONFIG_SYS_CLOCK_TICKS_PER_SEC=10000` (100 µs) so the symbol durations (all multiples of + 100 µs) schedule exactly. Assumes the PWM `set_pulse` path is ISR-safe (true for the + RP2040 driver, which only writes registers). + - On boards whose `ir-tx` is a plain **GPIO** (the ESP32/STM32 stubs) a software bit-banged + carrier on a cooperative thread is used as a portable fallback; this one is inherently + blocking (it toggles the pin at 56 kHz during marks). + - Queued frames (e.g. the BLE "bomb" burst) are sent back-to-back with a 50 ms inter-frame + gap so the receiver can separate them. A full hardware offload (RP2040 PIO+DMA / ESP32 + RMT) remains the optional last step for the fallback boards. +- **ESP32 / STM32 pin maps.** The `esp32_devkitc_wroom` and `nucleo_f401re` overlays remain + stubs with placeholder pins (RGB-PWM/ADC omitted, modules degrade to warnings) — verify + before use. ESP32 BLE/IR can later use the native controller + RMT. ## Build status -`west` / the Zephyr SDK is **not installed in the current environment**, so the application -has not been compiled here. The source, overlays, `prj.conf`, `Kconfig`, and `CMakeLists.txt` -follow Zephyr conventions and are intended to build per `zephyr_app/README.md`. Record -`west build` results here once a Zephyr environment is available. +`west` / the Zephyr SDK is **not installed in this environment**, so the firmware has not been +compiled here. The protocol layer (`olt_proto.c`) is verified by the host unit test +(`tests/olt_proto_test.c` — passing). Source, overlays, `prj.conf`, `Kconfig`, board `.conf` +files, and `CMakeLists.txt` follow Zephyr conventions and are intended to build per +`zephyr_app/README.md`. Primary target: **Pico W** (`rpi_pico/rp2040/w`). Record `west build` +results here once a Zephyr environment is available; the likeliest first fixups are the +RP2040 PWM pinctrl tokens and the Pico W BT controller availability in the chosen Zephyr tree. diff --git a/zephyr_app/Kconfig b/zephyr_app/Kconfig index 9690e4b..f61502f 100644 --- a/zephyr_app/Kconfig +++ b/zephyr_app/Kconfig @@ -10,6 +10,7 @@ config APP_BLE select BT select BT_PERIPHERAL select BT_GATT_DYNAMIC_DB + select BT_DEVICE_NAME_DYNAMIC help Enables the Nordic UART Service used for wireless command/telemetry (src/ble_nus.c). Requires a board with a Bluetooth controller, e.g. diff --git a/zephyr_app/README.md b/zephyr_app/README.md index d00cdf1..e6b73b3 100644 --- a/zephyr_app/README.md +++ b/zephyr_app/README.md @@ -43,23 +43,36 @@ source ~/zephyrproject/zephyr/zephyr-env.sh ## Build -From the repository root (after `west` and `ZEPHYR_BASE` are available): +From the repository root (after `west` and `ZEPHYR_BASE` are available). + +### Primary target — Raspberry Pi Pico W + +BLE is enabled automatically by `boards/rpi_pico_rp2040_w.conf`: ```bash -# Primary target (no BLE — the plain Pico has no radio) -west build -b rpi_pico zephyr_app +west build -b rpi_pico/rp2040/w zephyr_app # Clean rebuild -west build -p always -b rpi_pico zephyr_app +west build -p always -b rpi_pico/rp2040/w zephyr_app ``` -### With BLE (radio-capable boards) +> A working BLE link also needs the chosen Zephyr tree to provide the CYW43 +> Bluetooth HCI controller for this board. If it doesn't, build the plain +> `rpi_pico` target below (everything except BLE works). + +### Plain Pico (no radio) ```bash -# Raspberry Pi Pico W -west build -b rpi_pico/rp2040/w zephyr_app -- -DEXTRA_CONF_FILE=overlay-ble.conf +west build -b rpi_pico zephyr_app +``` -# ESP32 DevKitC +The heartbeat LED is on GPIO14 (external) so the same overlay is safe on both +the plain Pico and the Pico W — see the note in `boards/rpi_pico.overlay` if you +prefer the plain Pico's onboard GPIO25 LED. + +### ESP32 with BLE + +```bash west build -b esp32_devkitc_wroom/esp32/procpu zephyr_app -- -DEXTRA_CONF_FILE=overlay-ble.conf ``` @@ -91,12 +104,19 @@ west flash ## Notes / known limitations -- **IR carrier is software-generated** (bit-banged 56 kHz, ~39% duty) for - portability. Recommended upgrade: RP2040 PIO or ESP32 RMT. See - migration-notes.md. -- **Heartbeat LED** uses GPIO25 (valid on the plain Pico). The Pico W onboard - LED is behind the CYW43 chip — adjust the overlay if you need it there. -- **Battery divider ratio** and a few **PWM/ADC channel numbers** are - assumptions flagged as TODO in migration-notes.md; confirm against hardware. +- **IR TX is non-blocking on RP2040**: a hardware-PWM 56 kHz / 40 % carrier is + gated by a one-shot `k_timer` state machine, so transmission never busy-waits + and runs concurrently with IR RX, BLE and everything else. Boards whose + `ir-tx` is a plain GPIO fall back to a (blocking) software bit-banged carrier. + Full offload (PIO+DMA / RMT) is the optional next step. See migration-notes.md. +- **IR RX is non-blocking too**: edges are timestamped in a GPIO ISR, a frame + timeout closes the packet and decoding runs in a work item — so TX and RX + operate at the same time without blocking each other. +- **Heartbeat LED is on GPIO14** (external), deliberately *not* GPIO25 — that + pin is the CYW43 chip-select on the Pico W and must not be toggled. +- **No floating point**: all app math is integer (RGB duty, battery, timing) so + nothing soft-float is linked on the FPU-less Cortex-M0+. - Board-specific pins live in `boards/*.overlay`; behaviour lives in `src/`; tunables (timings, command table) live in `src/app_config.h`. +- The ESP32 and STM32 overlays are **stubs** with placeholder pins — verify + before use (migration-notes.md). diff --git a/zephyr_app/boards/esp32_devkitc_wroom.overlay b/zephyr_app/boards/esp32_devkitc_wroom.overlay index d63aded..0e36f42 100644 --- a/zephyr_app/boards/esp32_devkitc_wroom.overlay +++ b/zephyr_app/boards/esp32_devkitc_wroom.overlay @@ -34,6 +34,11 @@ label = "Piezo"; }; + /* + * IR TX as a plain GPIO -> src/ir_tx.c uses its software bit-bang + * carrier fallback here. For a precise carrier, convert this to an + * LEDC PWM node (like rpi_pico) or use the ESP32 RMT peripheral. + */ ir_tx_out: ir_tx_out { gpios = <&gpio0 23 GPIO_ACTIVE_HIGH>; /* main.py ESP32 IR TX */ label = "IR TX"; diff --git a/zephyr_app/boards/rpi_pico.overlay b/zephyr_app/boards/rpi_pico.overlay index 7658769..66b5007 100644 --- a/zephyr_app/boards/rpi_pico.overlay +++ b/zephyr_app/boards/rpi_pico.overlay @@ -4,7 +4,7 @@ * Pin assignments are ported directly from the MicroPython firmware: * - buttons GPIO18/19/20/21 (main.py:371, input pull-up, active-low) * - IR RX GPIO16 (main.py:200, TSOP4856, pull-up, active-low) - * - IR TX GPIO17 (main.py:223, gates IR LED via PN2222A) + * - IR TX GPIO17 (main.py:223, PWM 56 kHz carrier, PN2222A) * - piezo GPIO15 (main.py:44, NPN gate, active-high) * - RGB R/G/B GPIO22/26/27 (lib/rgb.py:17-19, PWM, active-high) * - battery GPIO28 / ADC2 (main.py:67, 2xAA via divider) @@ -20,12 +20,9 @@ / { aliases { - /* Heartbeat LED. On the plain Pico this is GPIO25; on the Pico W - * the onboard LED is behind the CYW43 chip, so we keep a GPIO - * alias for boards that expose one and fall back gracefully. */ heartbeat-led = &heartbeat_led; piezo = &piezo_out; - ir-tx = &ir_tx_out; + ir-tx = &ir_tx_pwm; ir-rx = &ir_rx_in; rgb-red = &rgb_red; rgb-green = &rgb_green; @@ -36,8 +33,19 @@ * The following four pins are driven directly with the GPIO API, so they * are bare nodes exposing a `gpios` property (no led/key driver binding). */ + + /* + * Heartbeat LED on GPIO14 (external LED + resistor to GND). + * + * IMPORTANT: the plain Pico's *onboard* LED is GPIO25, but on the + * Pico W (the primary target) GPIO25 is the CYW43439 SPI chip-select + * (WL_CS). Driving it would corrupt WiFi/BLE traffic, so we use a + * neutral external pin that is safe on every RP2040 variant. + * Plain-Pico users who prefer the onboard LED can change this to + * <&gpio0 25 GPIO_ACTIVE_HIGH>. + */ heartbeat_led: heartbeat_led { - gpios = <&gpio0 25 GPIO_ACTIVE_HIGH>; + gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>; label = "Heartbeat LED"; }; @@ -47,17 +55,6 @@ label = "Piezo"; }; - /* - * IR transmitter LED gate. The carrier (56 kHz) is generated in - * software by bit-banging this GPIO during marks (see src/ir_tx.c). - * This keeps the port board-portable; the recommended hardware upgrade - * is RP2040 PIO / ESP32 RMT (documented in migration-notes.md). - */ - ir_tx_out: ir_tx_out { - gpios = <&gpio0 17 GPIO_ACTIVE_HIGH>; - label = "IR TX"; - }; - /* IR receiver (demodulated output of TSOP4856), active-low, pull-up. */ ir_rx_in: ir_rx_in { gpios = <&gpio0 16 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; @@ -86,46 +83,76 @@ }; }; - /* RGB LED driven by hardware PWM at 100 Hz (lib/rgb.py). */ + /* + * RGB LED driven by hardware PWM at 100 Hz (lib/rgb.py). + * + * RP2040 PWM channel number = slice * 2 + (A:0 / B:1), where + * slice = (gpio / 2) % 8 and A/B follows the pin's parity: + * GPIO22 -> slice3, A -> 3*2+0 = 6 + * GPIO26 -> slice5, A -> 5*2+0 = 10 + * GPIO27 -> slice5, B -> 5*2+1 = 11 + * The matching carrier pins are routed in &pinctrl below. + */ rgb_leds: pwmleds { compatible = "pwm-leds"; rgb_red: rgb_red { - /* GPIO22 = PWM slice3 chan A -> channel 6 */ pwms = <&pwm 6 PWM_HZ(100) PWM_POLARITY_NORMAL>; label = "RGB Red"; }; rgb_green: rgb_green { - /* GPIO26 = PWM slice5 chan A -> channel 10 */ pwms = <&pwm 10 PWM_HZ(100) PWM_POLARITY_NORMAL>; label = "RGB Green"; }; rgb_blue: rgb_blue { - /* GPIO27 = PWM slice5 chan B -> channel 11 */ pwms = <&pwm 11 PWM_HZ(100) PWM_POLARITY_NORMAL>; label = "RGB Blue"; }; }; + /* + * IR transmitter carrier. GPIO17 gates the IR LED (via PN2222A). The + * 56 kHz / 40 % carrier is produced by hardware PWM; src/ir_tx.c flips + * the duty between 40 % (mark) and 0 % (space). Channel math as above: + * GPIO17 -> slice0, B -> 0*2+1 = 1 + */ + ir_carrier: ir_carrier { + compatible = "pwm-leds"; + ir_tx_pwm: ir_tx_pwm { + pwms = <&pwm 1 PWM_HZ(56000) PWM_POLARITY_NORMAL>; + label = "IR TX carrier"; + }; + }; + /* Battery sense channel for ADC_DT_SPEC_GET (src/battery.c). */ zephyr_user: zephyr_user { io-channels = <&adc 2>; }; }; -/* PWM pin routing for the RGB LED outputs (GPIO22/26/27). */ +/* + * PWM pin routing: RGB LED outputs (GPIO22/26/27) plus the IR TX carrier + * (GPIO17). All four are channels of the single RP2040 &pwm controller. + */ &pinctrl { - pwm_rgb_default: pwm_rgb_default { + pwm_default: pwm_default { group1 { - pinmux = , , ; + pinmux = , , , + ; }; }; }; &pwm { status = "okay"; - pinctrl-0 = <&pwm_rgb_default>; + pinctrl-0 = <&pwm_default>; pinctrl-names = "default"; - divider-int-0 = <255>; + /* + * No manual clock dividers: the RP2040 PWM driver derives each slice's + * divider from the requested period, so slice 0 (IR, 56 kHz) and slices + * 3/5 (RGB, 100 Hz) each get an appropriate divider automatically. If a + * future Zephyr requires it, the low 100 Hz RGB slices can be pinned + * with divider-int-3 / divider-int-5. + */ }; &adc { diff --git a/zephyr_app/boards/rpi_pico_rp2040_w.conf b/zephyr_app/boards/rpi_pico_rp2040_w.conf new file mode 100644 index 0000000..881cd2f --- /dev/null +++ b/zephyr_app/boards/rpi_pico_rp2040_w.conf @@ -0,0 +1,16 @@ +# Board-specific config, auto-merged when building for the Raspberry Pi Pico W +# (the primary target). The Pico W carries a CYW43439 with a Bluetooth +# controller, so BLE is enabled out of the box: +# +# west build -b rpi_pico/rp2040/w zephyr_app +# +# NOTE: a working BLE link additionally requires that the chosen Zephyr tree +# provides the CYW43 Bluetooth HCI controller for this board. If your Zephyr +# version does not, build the plain board instead (west build -b rpi_pico +# zephyr_app), which omits BLE. + +CONFIG_APP_BLE=y + +CONFIG_BT_DEVICE_NAME="OpenLaserTag" +CONFIG_BT_DEVICE_NAME_MAX=32 +CONFIG_BT_DEVICE_APPEARANCE=128 diff --git a/zephyr_app/overlay-ble.conf b/zephyr_app/overlay-ble.conf index 4c61dbc..7279753 100644 --- a/zephyr_app/overlay-ble.conf +++ b/zephyr_app/overlay-ble.conf @@ -1,16 +1,17 @@ -# Extra config fragment to enable the BLE Nordic UART service. -# Use on radio-capable boards only (Pico W, ESP32): -# west build -b rpi_pico/rp2040/w zephyr_app -- -DEXTRA_CONF_FILE=overlay-ble.conf -# west build -b esp32_devkitc_wroom/esp32/procpu zephyr_app -- -DEXTRA_CONF_FILE=overlay-ble.conf +# Extra config fragment to enable the BLE Nordic UART service on a +# radio-capable board that is NOT auto-configured (e.g. ESP32): +# west build -b esp32_devkitc_wroom/esp32/procpu zephyr_app -- \ +# -DEXTRA_CONF_FILE=overlay-ble.conf +# +# The Pico W enables BLE automatically via boards/rpi_pico_rp2040_w.conf, so it +# does NOT need this file. +# +# CONFIG_APP_BLE selects BT, BT_PERIPHERAL, BT_GATT_DYNAMIC_DB and +# BT_DEVICE_NAME_DYNAMIC (see Kconfig); only the tunables below remain. CONFIG_APP_BLE=y -CONFIG_BT=y -CONFIG_BT_PERIPHERAL=y +# Advertised/default name (overridden at runtime with the device id). CONFIG_BT_DEVICE_NAME="OpenLaserTag" +CONFIG_BT_DEVICE_NAME_MAX=32 CONFIG_BT_DEVICE_APPEARANCE=128 -CONFIG_BT_GATT_DYNAMIC_DB=y - -# Nordic UART payloads can reach the ATT MTU; give the RX buffer room. -CONFIG_BT_BUF_ACL_RX_SIZE=27 -CONFIG_BT_L2CAP_TX_MTU=23 diff --git a/zephyr_app/prj.conf b/zephyr_app/prj.conf index 277de53..7182f2d 100644 --- a/zephyr_app/prj.conf +++ b/zephyr_app/prj.conf @@ -23,5 +23,15 @@ CONFIG_MAIN_STACK_SIZE=4096 # Cycle counter is used for microsecond IR edge timestamps. CONFIG_TIMING_FUNCTIONS=y +# 100 us tick so the non-blocking IR TX state machine can schedule symbols +# accurately (all OLT mark/space durations are multiples of 100 us, so they map +# to an exact whole number of ticks; see src/ir_tx.c). Tickless kernel keeps the +# overhead low - the rate only sets the timeout granularity. +CONFIG_SYS_CLOCK_TICKS_PER_SEC=10000 + # Hardware unique id -> device id string (replaces machine.unique_id()). CONFIG_HWINFO=y + +# Optimize for size: the RP2040 has limited flash and no FPU. All app math is +# integer, so no floating-point support is pulled in. +CONFIG_SIZE_OPTIMIZATIONS=y diff --git a/zephyr_app/src/app_config.h b/zephyr_app/src/app_config.h index cc1e5c5..6c9c9f5 100644 --- a/zephyr_app/src/app_config.h +++ b/zephyr_app/src/app_config.h @@ -39,12 +39,16 @@ /* ---- Battery monitor (main.py:74-108) -------------------------------- */ #define BATTERY_PERIOD_MS 10000U -/* ADC full-scale reference, volts. Pico VSYS sense is on a divider. */ +/* ADC full-scale reference in millivolts (RP2040 ADC, 3.3 V rail). */ #define BATTERY_VREF_MV 3300 /* - * The MicroPython code computes capacity from voltage/2 (main.py:87), - * i.e. it assumes a 1:2 divider so the measured rail maps to one alkaline - * cell. TODO: confirm the real divider ratio on the PCB (see migration-notes). + * The 2-cell AA pack (~3 V) is sampled directly on the ADC pin and the + * per-cell voltage is derived in firmware by dividing by the cell count + * (main.py:87 feeds voltage/2 to a single-cell alkaline capacity model). + * So this is the number of series cells, not a resistor-divider ratio: + * cell_mv = measured_mv * NUM / DEN + * Fresh 2xAA ~3.2 V stays within the 3.3 V ADC range. If you add a resistor + * divider in front of the ADC, fold its ratio into these two constants. */ #define BATTERY_DIVIDER_NUM 1 #define BATTERY_DIVIDER_DEN 2 diff --git a/zephyr_app/src/ir_rx.c b/zephyr_app/src/ir_rx.c index dc59fb2..e62098e 100644 --- a/zephyr_app/src/ir_rx.c +++ b/zephyr_app/src/ir_rx.c @@ -34,10 +34,12 @@ LOG_MODULE_REGISTER(ir_rx, LOG_LEVEL_INF); static const struct gpio_dt_spec ir_rx = GPIO_DT_SPEC_GET(IR_RX_NODE, gpios); static struct gpio_callback ir_rx_cb_data; -static uint32_t edge_times[IR_RX_MAX_EDGES]; +/* Raw cycle timestamps are captured in the ISR (cheap); the conversion to + * microseconds happens off the interrupt path, in decode_work_fn(). */ +static uint32_t edge_cycles[IR_RX_MAX_EDGES]; static volatile uint32_t edge_count; -static uint32_t decode_times[IR_RX_MAX_EDGES]; +static uint32_t decode_cycles[IR_RX_MAX_EDGES]; static uint32_t decode_count; static ir_rx_cb_t user_cb; @@ -46,8 +48,17 @@ static void decode_work_fn(struct k_work *work) { ARG_UNUSED(work); + /* Convert captured cycles to absolute microseconds (relative to the + * first edge) for the timing-based decoder. 32-bit deltas are safe: a + * frame is < ~50 ms, far below the counter wrap at any clock rate. */ + uint32_t us_times[IR_RX_MAX_EDGES]; + for (uint32_t i = 0; i < decode_count; i++) { + us_times[i] = k_cyc_to_us_floor32(decode_cycles[i] - + decode_cycles[0]); + } + struct olt_frame f; - if (olt_decode(decode_times, decode_count, &f)) { + if (olt_decode(us_times, decode_count, &f)) { LOG_DBG("RX 0x%06X %s", f.packet, f.is_command ? "command" : "shot"); if (user_cb) { @@ -68,7 +79,7 @@ static void frame_timer_fn(struct k_timer *t) if (decode_count > IR_RX_MAX_EDGES) { decode_count = IR_RX_MAX_EDGES; } - memcpy(decode_times, edge_times, decode_count * sizeof(edge_times[0])); + memcpy(decode_cycles, edge_cycles, decode_count * sizeof(edge_cycles[0])); edge_count = 0; /* ready for the next frame */ k_work_submit(&decode_work); } @@ -81,13 +92,13 @@ static void ir_rx_isr(const struct device *port, struct gpio_callback *cb, ARG_UNUSED(cb); ARG_UNUSED(pins); - uint32_t now = k_cyc_to_us_floor32(k_cycle_get_32()); + uint32_t now = k_cycle_get_32(); if (edge_count == 0) { k_timer_start(&frame_timer, K_MSEC(IR_RX_FRAME_MS), K_NO_WAIT); } if (edge_count < IR_RX_MAX_EDGES) { - edge_times[edge_count++] = now; + edge_cycles[edge_count++] = now; } } #endif /* IR_RX_PRESENT */ diff --git a/zephyr_app/src/ir_tx.c b/zephyr_app/src/ir_tx.c index b60cd34..c284918 100644 --- a/zephyr_app/src/ir_tx.c +++ b/zephyr_app/src/ir_tx.c @@ -1,17 +1,27 @@ /* - * ir_tx.c - OpenLaserTag IR transmitter with a software carrier. + * ir_tx.c - OpenLaserTag IR transmitter. * - * The original firmware used RP2040 PIO / ESP32 RMT to gate a hardware 56 kHz - * carrier (lib/olt_lib/ir_tx). For a board-portable first pass we generate the - * carrier in software: a dedicated cooperative thread toggles the IR-TX GPIO - * at ~56 kHz during "mark" symbols and holds it low during "space" symbols. + * Two carrier backends are selected automatically from the `ir-tx` devicetree + * alias: * - * Trade-off (see migration-notes.md): this monopolises a high-priority thread - * for the duration of a frame (~30 ms) and the carrier frequency/duty are - * approximate. The recommended upgrade is PWM-gated PIO/RMT. + * - PWM (node has a `pwms` property, e.g. rpi_pico - the primary path): + * fully NON-BLOCKING and real-time. The 56 kHz / 40 % carrier is produced + * by hardware PWM; a one-shot k_timer drives a symbol state machine. Each + * timer expiry (in the system-clock ISR) just flips the PWM duty between + * 40 % (mark) and 0 % (space) and arms the next symbol. The CPU is free + * between symbols, so TX runs concurrently with IR RX and everything else + * and never busy-waits. + * + * - GPIO (node has a `gpios` property, e.g. the ESP32/STM32 stubs): a + * software bit-banged carrier on a dedicated cooperative thread. This is + * inherently blocking (it must toggle the pin at 56 kHz during marks) and + * is only a portable fallback for boards whose PWM is not configured yet. + * + * Multiple queued frames (e.g. the BLE "bomb" burst) are transmitted + * back-to-back with an inter-frame gap so the receiver can separate them. + * The original used PIO/RMT (lib/olt_lib/ir_tx). */ #include -#include #include #include "app_config.h" @@ -22,14 +32,15 @@ LOG_MODULE_REGISTER(ir_tx, LOG_LEVEL_INF); #define IR_TX_NODE DT_ALIAS(ir_tx) -/* - * Carrier approximation. Target period is 1e6/56000 = 17.86 us. With 1 us - * busy-wait granularity we use 7 us on + 11 us off = 18 us (~55.6 kHz, ~39% - * duty), which the 56 kHz TSOP demodulator tolerates. - */ -#define CARRIER_ON_US 7U -#define CARRIER_OFF_US 11U -#define CARRIER_PERIOD_US (CARRIER_ON_US + CARRIER_OFF_US) +#if DT_NODE_HAS_STATUS(IR_TX_NODE, okay) && DT_NODE_HAS_PROP(IR_TX_NODE, pwms) +#define IR_TX_PWM 1 +#elif DT_NODE_HAS_STATUS(IR_TX_NODE, okay) && DT_NODE_HAS_PROP(IR_TX_NODE, gpios) +#define IR_TX_GPIO 1 +#endif + +/* Quiet time between consecutive frames so the RX frame-timeout (~47 ms) closes + * the previous frame before the next one starts. */ +#define IR_TX_GAP_MS 50 struct ir_tx_req { uint8_t tx1; @@ -37,13 +48,136 @@ struct ir_tx_req { uint8_t tx3; }; +/* Pending frames queued from any context (button/BLE/main). */ K_MSGQ_DEFINE(ir_tx_q, sizeof(struct ir_tx_req), 8, 4); -#if DT_NODE_HAS_STATUS(IR_TX_NODE, okay) -#define IR_TX_PRESENT 1 +/* ================================================================== */ +/* PWM backend: non-blocking timer-driven symbol state machine */ +/* ================================================================== */ +#if defined(IR_TX_PWM) +#include + +static const struct pwm_dt_spec carrier = PWM_DT_SPEC_GET(IR_TX_NODE); +static uint32_t mark_pulse; /* PWM pulse width (ns) for a "mark" (40 % duty) */ + +enum tx_state { TX_IDLE, TX_FRAME, TX_GAP }; +static volatile enum tx_state state = TX_IDLE; +static struct k_spinlock lock; + +static uint16_t sym[OLT_MAX_SYMBOLS]; +static size_t sym_n; +static size_t sym_idx; +static struct k_timer tx_timer; + +/* Even symbol index = mark (carrier on), odd = space (carrier off). */ +static inline void carrier_apply(size_t idx) +{ + (void)pwm_set_pulse_dt(&carrier, (idx & 1U) ? 0U : mark_pulse); +} + +static inline void carrier_idle(void) +{ + (void)pwm_set_pulse_dt(&carrier, 0); +} + +/* Pop the next queued frame into sym[]. Returns true if one was loaded. */ +static bool load_next(void) +{ + struct ir_tx_req r; + + if (k_msgq_get(&ir_tx_q, &r, K_NO_WAIT) != 0) { + return false; + } + sym_n = olt_encode(r.tx1, r.tx2, r.tx3, sym); + sym_idx = 0; + return true; +} + +/* Apply the current symbol and arm the timer for its duration. */ +static void apply_and_arm(void) +{ + carrier_apply(sym_idx); + k_timer_start(&tx_timer, K_USEC(sym[sym_idx]), K_NO_WAIT); +} + +/* Runs in the system-clock ISR at each symbol/gap boundary. */ +static void tx_timer_fn(struct k_timer *t) +{ + ARG_UNUSED(t); + + switch (state) { + case TX_FRAME: + if (++sym_idx < sym_n) { + apply_and_arm(); + break; + } + /* Frame finished. */ + carrier_idle(); + if (k_msgq_num_used_get(&ir_tx_q) > 0) { + state = TX_GAP; + k_timer_start(&tx_timer, K_MSEC(IR_TX_GAP_MS), K_NO_WAIT); + } else { + state = TX_IDLE; + } + break; + + case TX_GAP: + if (load_next()) { + state = TX_FRAME; + apply_and_arm(); + } else { + state = TX_IDLE; + } + break; + + default: + break; + } +} + +/* Kick the state machine if it is idle (called from thread context). */ +static void ir_tx_kick(void) +{ + k_spinlock_key_t key = k_spin_lock(&lock); + + if (state == TX_IDLE && load_next()) { + state = TX_FRAME; + apply_and_arm(); + } + k_spin_unlock(&lock, key); +} + +static int carrier_init(void) +{ + if (!pwm_is_ready_dt(&carrier)) { + LOG_ERR("IR TX PWM not ready"); + return -ENODEV; + } + mark_pulse = (uint32_t)(((uint64_t)carrier.period * + OLT_CARRIER_DUTY_PCT) / 100U); + k_timer_init(&tx_timer, tx_timer_fn, NULL); + carrier_idle(); + LOG_INF("IR TX initialized (non-blocking hardware PWM carrier)"); + return 0; +} + +/* ================================================================== */ +/* GPIO backend: software bit-bang fallback (blocking, on a thread) */ +/* ================================================================== */ +#elif defined(IR_TX_GPIO) +#include + static const struct gpio_dt_spec ir_tx = GPIO_DT_SPEC_GET(IR_TX_NODE, gpios); -/* Emit carrier for `us` microseconds (gpio active = LED on). */ +/* + * Carrier approximation. Target period 1e6/56000 = 17.86 us; with 1 us + * busy-wait granularity we use 7 us on + 11 us off = 18 us (~55.6 kHz, ~39 % + * duty), which the 56 kHz TSOP demodulator tolerates. + */ +#define CARRIER_ON_US 7U +#define CARRIER_OFF_US 11U +#define CARRIER_PERIOD_US (CARRIER_ON_US + CARRIER_OFF_US) + static void mark(uint32_t us) { uint32_t periods = (us + CARRIER_PERIOD_US / 2) / CARRIER_PERIOD_US; @@ -56,7 +190,6 @@ static void mark(uint32_t us) } } -/* Hold the carrier off for `us` microseconds. */ static void space(uint32_t us) { gpio_pin_set_dt(&ir_tx, 0); @@ -68,7 +201,6 @@ static void transmit(const struct ir_tx_req *req) uint16_t sym[OLT_MAX_SYMBOLS]; size_t n = olt_encode(req->tx1, req->tx2, req->tx3, sym); - /* Even indices are marks (carrier on), odd indices are spaces. */ for (size_t i = 0; i < n; i++) { if ((i & 1U) == 0U) { mark(sym[i]); @@ -78,8 +210,8 @@ static void transmit(const struct ir_tx_req *req) } gpio_pin_set_dt(&ir_tx, 0); } -#endif /* IR_TX_PRESENT */ +/* Dedicated cooperative thread drains the queue and bit-bangs each frame. */ static void ir_tx_thread(void *a, void *b, void *c) { ARG_UNUSED(a); @@ -90,17 +222,34 @@ static void ir_tx_thread(void *a, void *b, void *c) for (;;) { k_msgq_get(&ir_tx_q, &req, K_FOREVER); -#if IR_TX_PRESENT LOG_DBG("TX 0x%02X%02X%02X", req.tx1, req.tx2, req.tx3); transmit(&req); -#endif + k_msleep(IR_TX_GAP_MS); /* inter-frame gap for the receiver */ } } - -/* Cooperative priority so the bit-banged carrier is not preempted. */ K_THREAD_DEFINE(ir_tx_tid, 1024, ir_tx_thread, NULL, NULL, NULL, K_PRIO_COOP(4), 0, 0); +static int carrier_init(void) +{ + if (!gpio_is_ready_dt(&ir_tx)) { + LOG_ERR("IR TX GPIO not ready"); + return -ENODEV; + } + int ret = gpio_pin_configure_dt(&ir_tx, GPIO_OUTPUT_INACTIVE); + + if (ret) { + LOG_ERR("IR TX configure failed: %d", ret); + return ret; + } + LOG_INF("IR TX initialized (software bit-bang carrier, blocking)"); + return 0; +} +#endif /* carrier backend */ + +/* ================================================================== */ +/* Public API */ +/* ================================================================== */ int ir_tx_send(uint8_t tx1, uint8_t tx2, uint8_t tx3) { struct ir_tx_req req = { tx1, tx2, tx3 }; @@ -109,24 +258,18 @@ int ir_tx_send(uint8_t tx1, uint8_t tx2, uint8_t tx3) if (ret) { LOG_WRN("IR TX queue full, dropping 0x%02X%02X%02X", tx1, tx2, tx3); + return ret; } - return ret; +#if defined(IR_TX_PWM) + ir_tx_kick(); /* GPIO backend's thread is already waiting on the queue */ +#endif + return 0; } int ir_tx_init(void) { -#if IR_TX_PRESENT - if (!gpio_is_ready_dt(&ir_tx)) { - LOG_ERR("IR TX GPIO not ready"); - return -ENODEV; - } - int ret = gpio_pin_configure_dt(&ir_tx, GPIO_OUTPUT_INACTIVE); - if (ret) { - LOG_ERR("IR TX configure failed: %d", ret); - return ret; - } - LOG_INF("IR TX initialized (software 56 kHz carrier)"); - return 0; +#if defined(IR_TX_PWM) || defined(IR_TX_GPIO) + return carrier_init(); #else LOG_WRN("IR TX alias not configured"); return 0; diff --git a/zephyr_app/src/rgb_led.c b/zephyr_app/src/rgb_led.c index 5866c0a..c58c84f 100644 --- a/zephyr_app/src/rgb_led.c +++ b/zephyr_app/src/rgb_led.c @@ -1,6 +1,9 @@ /* - * rgb_led.c - PWM RGB LED, port of lib/rgb.py. + * rgb_led.c - PWM RGB LED, integer port of lib/rgb.py. + * * Uses pwm_dt_spec from the `rgb-red/green/blue` aliases (pwm-leds nodes). + * Channel duty = level/255 * power/100, computed in 64-bit integer math so no + * floating point is required on the Cortex-M0+. */ #include #include @@ -26,24 +29,19 @@ static const struct pwm_dt_spec led_b = PWM_DT_SPEC_GET(RGB_B_NODE); #warning "RGB aliases (rgb-red/green/blue) not found in overlay" #endif -static float rgb_pwr = 1.0f; /* lib/rgb.py default */ +/* "White" preset brightness, ~0.8 on the original 0.0-1.0 scale. */ +#define RGB_WHITE_LEVEL 204U -static float clampf(float v) -{ - if (v < 0.0f) { - return 0.0f; - } - if (v > 1.0f) { - return 1.0f; - } - return v; -} +static uint8_t rgb_pwr = 100; /* percent; lib/rgb.py default 1.0 */ #if RGB_PRESENT -static void set_channel(const struct pwm_dt_spec *led, float level) +static void set_channel(const struct pwm_dt_spec *led, uint8_t level) { - uint32_t pulse = (uint32_t)(clampf(level) * (float)led->period); + /* pulse = period * level/255 * pwr/100 (64-bit to avoid overflow). */ + uint32_t pulse = (uint32_t)(((uint64_t)led->period * level * rgb_pwr) / + (255U * 100U)); int ret = pwm_set_pulse_dt(led, pulse); + if (ret) { LOG_WRN("pwm set failed: %d", ret); } @@ -67,12 +65,12 @@ int rgb_init(void) #endif } -void rgb_set(float r, float g, float b) +void rgb_set(uint8_t r, uint8_t g, uint8_t b) { #if RGB_PRESENT - set_channel(&led_r, r * rgb_pwr); - set_channel(&led_g, g * rgb_pwr); - set_channel(&led_b, b * rgb_pwr); + set_channel(&led_r, r); + set_channel(&led_g, g); + set_channel(&led_b, b); #else ARG_UNUSED(r); ARG_UNUSED(g); @@ -80,37 +78,37 @@ void rgb_set(float r, float g, float b) #endif } -void rgb_set_power(float pwr) +void rgb_set_power(uint8_t pct) { - rgb_pwr = clampf(pwr); + rgb_pwr = pct > 100U ? 100U : pct; } -float rgb_get_power(void) +uint8_t rgb_get_power(void) { return rgb_pwr; } void rgb_off(void) { - rgb_set(0.0f, 0.0f, 0.0f); + rgb_set(0, 0, 0); } void rgb_red(void) { - rgb_set(1.0f, 0.0f, 0.0f); + rgb_set(255, 0, 0); } void rgb_green(void) { - rgb_set(0.0f, 1.0f, 0.0f); + rgb_set(0, 255, 0); } void rgb_blue(void) { - rgb_set(0.0f, 0.0f, 1.0f); + rgb_set(0, 0, 255); } void rgb_white(void) { - rgb_set(0.8f, 0.8f, 0.8f); + rgb_set(RGB_WHITE_LEVEL, RGB_WHITE_LEVEL, RGB_WHITE_LEVEL); } diff --git a/zephyr_app/src/rgb_led.h b/zephyr_app/src/rgb_led.h index 600fb71..390f5d0 100644 --- a/zephyr_app/src/rgb_led.h +++ b/zephyr_app/src/rgb_led.h @@ -1,19 +1,24 @@ /* * rgb_led.h - RGB status LED via PWM. + * * Ported from lib/rgb.py (GPIO22/26/27, 100 Hz, active-high, global power - * scaling 0.0-1.0). Channel values are 0.0-1.0 like the Python API. + * scaling). The Python API used 0.0-1.0 floats; this port uses 8-bit integer + * channel values (0-255) and an integer power percentage so the RP2040 + * Cortex-M0+ (no FPU) never pulls in soft-float routines. */ #ifndef RGB_LED_H #define RGB_LED_H +#include + int rgb_init(void); -/* Set the three channels (0.0-1.0); scaled by the current power level. */ -void rgb_set(float r, float g, float b); +/* Set the three channels (0-255 each); scaled by the current power level. */ +void rgb_set(uint8_t r, uint8_t g, uint8_t b); -/* Global power multiplier (lib/rgb.py rgb_pwr), clamped to 0.0-1.0. */ -void rgb_set_power(float pwr); -float rgb_get_power(void); +/* Global power multiplier as a percentage (0-100); lib/rgb.py rgb_pwr. */ +void rgb_set_power(uint8_t pct); +uint8_t rgb_get_power(void); /* Presets matching lib/rgb.py. */ void rgb_off(void);