Make IR real-time/non-blocking, harden for Pico W, optimize

Primary target is now the Pico W; functionality preserved on other boards.

IR TX (RP2040/PWM): rewritten to a non-blocking, real-time symbol state
machine. A hardware-PWM 56 kHz/40% carrier is gated by a one-shot k_timer
(mark/space flips in the timer ISR), so TX never busy-waits and runs
concurrently with IR RX, BLE and everything else. Queued frames (BLE "bomb")
are sent back-to-back with a 50 ms inter-frame gap. Boards whose ir-tx is a
plain GPIO keep the (blocking) software bit-bang carrier as a fallback.

IR RX: cycle timestamps captured in the ISR, converted to us off the
interrupt path in the decode work item (lower ISR jitter).

Fixes:
- Heartbeat moved off GPIO25 (CYW43 WL_CS on Pico W) to GPIO14 to avoid
  corrupting WiFi/BLE.
- APP_BLE now selects BT_DEVICE_NAME_DYNAMIC (bt_set_name needs it).

Optimizations:
- RGB rewritten with integer math (no soft-float on the FPU-less M0+).
- CONFIG_SIZE_OPTIMIZATIONS; 100 us tick so OLT symbol durations schedule
  exactly.

Pico W: boards/rpi_pico_rp2040_w.conf enables BLE out of the box.

Docs: resolved hardware TODOs (battery cell-count divider, PWM channel math,
heartbeat, carrier), README primary target = Pico W, inline comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
njord 2026-06-19 16:44:39 +02:00
parent 9149761c82
commit f2de891ea3
13 changed files with 421 additions and 159 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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).

View File

@ -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";

View File

@ -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 = <PWM_3A_P22>, <PWM_5A_P26>, <PWM_5B_P27>;
pinmux = <PWM_3A_P22>, <PWM_5A_P26>, <PWM_5B_P27>,
<PWM_0B_P17>;
};
};
};
&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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 */

View File

@ -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 <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/logging/log.h>
#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 <zephyr/drivers/pwm.h>
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 <zephyr/drivers/gpio.h>
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;

View File

@ -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 <zephyr/kernel.h>
#include <zephyr/drivers/pwm.h>
@ -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);
}

View File

@ -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 <stdint.h>
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);