Add Zephyr RTOS port of OpenLaserTag remote control #1

Open
njord wants to merge 4 commits from port-zephyr into master
35 changed files with 2790 additions and 0 deletions

34
.claude/settings.json Normal file
View File

@ -0,0 +1,34 @@
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read",
"Write",
"Edit",
"MultiEdit",
"Grep",
"Glob",
"LS",
"Bash(git status*)",
"Bash(git diff*)",
"Bash(ls *)",
"Bash(find *)",
"Bash(west build*)",
"Bash(cmake *)",
"Bash(ninja *)",
"Bash(python3 -m compileall*)"
],
"ask": [
"Bash(git commit*)",
"Bash(west flash*)",
"Bash(west debug*)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(git push*)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(sudo *)"
]
}
}

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
build/
*.pyc
__pycache__/
.DS_Store

198
CLAUDE.md Normal file
View File

@ -0,0 +1,198 @@
# Project: MicroPython to Zephyr port
## Goal
Port this existing MicroPython firmware to Zephyr RTOS for MCU boards similar to:
- Raspberry Pi Pico / RP2040 / RP2350
- STM32
- ESP32
The goal is a maintainable Zephyr C/C++ firmware, not a line-by-line translation.
## Primary strategy
Use the original MicroPython code as the reference implementation and create a clean Zephyr application beside it.
Do not directly mimic Python structure if a Zephyr-native structure is better. Preserve behavior, timing, hardware interactions, and safety assumptions.
## Required workflow
Claude must follow this order:
1. Inspect the repository structure.
2. Identify all MicroPython entry points, usually:
- `main.py`
- `boot.py`
- `lib/`
- `src/`
- board-specific files
3. Create or update `docs/migration-notes.md`.
4. Create a hardware/peripheral inventory.
5. Create a module-by-module migration map.
6. Create or update the Zephyr app under `zephyr_app/`.
7. Port the smallest buildable slice first.
8. Run `git diff` after each meaningful change.
9. Run `west build` if Zephyr is available.
10. If build fails, fix iteratively.
11. If Zephyr is not installed locally, still produce correct source/config files and document exact setup steps.
## Strict rules
- Do not delete the original MicroPython implementation.
- If restructuring is needed, preserve original code under `legacy_micropython/`.
- Do not make one huge rewrite.
- Prefer small, reviewable edits.
- Do not run destructive commands.
- Do not push to git remotes.
- Do not flash hardware unless explicitly requested.
- Do not assume pin mappings when they are unclear; document assumptions in `docs/migration-notes.md`.
- Prefer Zephyr portable APIs over vendor-specific SDK calls.
- Prefer devicetree overlays over hardcoded pin numbers.
- Avoid dynamic allocation unless clearly justified.
- Keep board-specific configuration separate.
## Hardware inventory checklist
Before implementation, identify:
- GPIO inputs/outputs
- button polarity and pull-up/pull-down behavior
- LEDs and active-high/active-low behavior
- timers
- sleep/delay timing
- PWM channels
- ADC channels and scaling assumptions
- I2C buses, addresses, speeds
- SPI buses, chip-selects, modes, speeds
- UART ports and baud rates
- Wi-Fi/BLE/networking usage
- filesystem/storage usage
- watchdog usage
- interrupt-like callbacks
- async/event-loop behavior
- power-management assumptions
## MicroPython to Zephyr mapping
Use these mappings as the starting point:
- `machine.Pin` -> Zephyr GPIO API using `gpio_dt_spec`
- `machine.PWM` -> Zephyr PWM API
- `machine.ADC` -> Zephyr ADC API
- `machine.I2C` -> Zephyr I2C API
- `machine.SPI` -> Zephyr SPI API
- `machine.UART` -> Zephyr UART API
- `time.sleep_ms()` -> `k_msleep()`
- `time.sleep_us()` -> `k_usleep()`
- `Timer` callbacks -> Zephyr timers, work queues, or threads
- polling loops -> Zephyr thread, timer, work queue, or explicit state machine
- `uasyncio` -> Zephyr threads/events/work queues/state machine
- `network` on ESP32 -> Zephyr networking/Wi-Fi stack if needed
- `json`, `struct`, protocol code -> native C implementation or small helper modules
- Python dictionaries/config -> C structs, Kconfig, devicetree, or generated config
## Target output structure
```text
zephyr_app/
CMakeLists.txt
prj.conf
README.md
src/
main.c
app_config.h
board_io.c
board_io.h
boards/
rpi_pico.overlay
esp32_devkitc_wroom.overlay
nucleo_f401re.overlay
docs/
migration-notes.md
migration-prompt.md
.claude/
settings.json
legacy_micropython/
...
```
## Board priority
Default target order:
1. `rpi_pico`
2. `esp32_devkitc_wroom`
3. `nucleo_f401re` as generic STM32 reference unless another STM32 board is evident
If the project indicates a different STM32 board, use that board instead.
## Zephyr conventions
Use:
- `gpio_dt_spec` for GPIO
- devicetree aliases for board-specific pins
- `LOG_MODULE_REGISTER`
- `CONFIG_LOG`
- clear init functions
- explicit error checking
- `k_msleep` for simple periodic loops
- work queues/timers for callback-style behavior
- `prj.conf` for app features
- board overlays for pin mapping
Avoid:
- hardcoded GPIO controller names
- hardcoded peripheral instances unless unavoidable
- busy waiting
- large global mutable state without structure
- vendor SDK calls unless no Zephyr API exists
## Build commands
Use these first:
```bash
west build -b rpi_pico zephyr_app
west build -b esp32_devkitc_wroom zephyr_app
west build -b nucleo_f401re zephyr_app
```
Clean rebuild examples:
```bash
west build -p always -b rpi_pico zephyr_app
```
Flash only when explicitly requested:
```bash
west flash
```
## Deliverables
Claude should produce:
- `zephyr_app/` with buildable Zephyr skeleton
- migrated core logic where possible
- board overlays
- `prj.conf`
- `zephyr_app/README.md`
- `docs/migration-notes.md`
- TODO list for hardware assumptions
- notes about what was not ported yet and why
## Definition of done
A first pass is acceptable when:
- the Zephyr app has a valid structure
- the build system files exist
- board overlays exist
- core MicroPython logic is mapped into C structure
- unresolved hardware questions are documented
- build commands are documented
- no original MicroPython code was destroyed

154
docs/migration-notes.md Normal file
View File

@ -0,0 +1,154 @@
# Migration notes — OpenLaserTag remote control (MicroPython → Zephyr)
Status: first-pass full port. Source files live under `zephyr_app/`. The original
MicroPython firmware (`main.py`, `lib/`) is untouched and remains the reference
implementation.
## Source inventory
| MicroPython file | Responsibility |
|---|---|
| `main.py` | uasyncio app: device id, piezo, battery monitor, BLE RX handler, OLT command table, IR TX/RX wiring, buttons, heartbeat, main loop |
| `lib/rgb.py` | `RGB` class: 3× `machine.PWM` (GPIO22/26/27) @100 Hz, color presets, global power scaling |
| `lib/ble_uart/__init__.py` | `Ble_uart`: Nordic UART GATT service, RX append buffer, notify TX, advertise |
| `lib/ble_uart/ble_advertising.py` | Advertising payload TLV encoder |
| `lib/olt_lib/ir_tx/__init__.py` | `IR` base: builds µs mark/space array, ISR/RMT/PIO transmit |
| `lib/olt_lib/ir_tx/olt.py` | `LT_24` encoder: 24-bit, bit-reversed, 2400/600/1200 µs timing, 56 kHz |
| `lib/olt_lib/ir_tx/rp2_rmt.py` | RP2040 PIO carrier driver (platform-specific) |
| `lib/olt_lib/ir_rx/__init__.py` | `IR_RX` base: pin IRQ records edge times, one-shot timer triggers decode |
| `lib/olt_lib/ir_rx/olt.py` | `LT_24` decoder: validates edges, extracts 24-bit packet |
| `lib/primitives/pushbutton.py` | debounced button: press/release/long(1000 ms)/double(400 ms) |
| `lib/primitives/aadc.py` | async ADC polling wrapper |
| `lib/logging.py` | custom logging (replaced by Zephyr `LOG`) |
## Hardware map
| Function | MicroPython source | Pin / peripheral | Zephyr mapping | Status |
|---|---|---:|---|---|
| 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`, 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 | 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
| MicroPython | Zephyr | Status |
|---|---|---|
| `main.py` glue / `asyncio.run` | `src/main.c` | Ported |
| OLT encode/decode + tables | `src/olt_proto.{c,h}`, `src/app_config.h` | Ported |
| piezo beeps | `src/piezo.{c,h}` | Ported |
| `Pushbutton` tasks → IR | `src/buttons.{c,h}` | Ported |
| `lib/rgb.py` | `src/rgb_led.{c,h}` | Ported |
| `battery_monitor` + `AADC` | `src/battery.{c,h}` | Ported |
| `ir_tx` (IR/LT_24) | `src/ir_tx.{c,h}` | Ported (SW carrier) |
| `ir_rx` (IR_RX/LT_24) | `src/ir_rx.{c,h}` | Ported |
| `ble_uart` | `src/ble_nus.{c,h}` | Ported (gated) |
| onboard LED heartbeat / unique_id | `src/board_io.{c,h}` | Ported |
## Concurrency mapping (uasyncio → Zephyr)
| uasyncio | Zephyr |
|---|---|
| `alive_check` | `k_timer` toggling heartbeat LED |
| `battery_monitor` (10 s) | `k_timer` → work item: ADC read + BLE notify |
| `Pushbutton` events | GPIO IRQ → `k_work_delayable` debounce/long/double |
| 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 | non-blocking `k_timer` symbol state machine + hardware PWM (RP2040); cooperative thread + bit-bang fallback |
## API mapping
| MicroPython API | Zephyr API | Notes |
|---|---|---|
| `machine.Pin` | `gpio_dt_spec` | devicetree aliases / `gpio-keys` |
| `machine.PWM` | `pwm_dt_spec` | RGB via `pwm-leds` |
| `machine.ADC` | `adc_dt_spec` | `zephyr_user` io-channel |
| `time.sleep_ms` | `k_msleep` | — |
| `time.sleep_us` / busy timing | `k_busy_wait` | only inside IR TX carrier |
| `ticks_us` | `k_cycle_get_32` + `k_cyc_to_us_*` | IR RX edge timing |
| `Timer` one-shot | `k_timer` | IR RX frame timeout |
| `uasyncio` tasks | threads / workqueue / `k_timer` | see table above |
| `bluetooth` NUS | Zephyr `bluetooth/gatt` | custom 128-bit service |
| custom `logging` | `LOG_MODULE_REGISTER` / `LOG_*` | — |
## OLT protocol summary
- 24-bit payload `tx1<<16 | tx2<<8 | tx3`, transmitted bit-reversed, LSB-first on the wire.
- Frame: lead mark 2400 µs + space 600 µs, then 24 bits: mark 1200 µs (1) or 600 µs (0),
each followed by a 600 µs space. Carrier 56 kHz, ~40 % duty.
- `bit23` (`0x800000`) = command packet; otherwise a shot packet.
- Shot decode (`packet >> 10`): `id = v>>6`, `team = (v>>4)&0x3`, `damage = v&0xF`
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`.
## Resolved hardware questions (second pass)
- **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
Built and verified with **Zephyr v3.7.0** + **Zephyr SDK 0.16.8** (cmake 4.3.2, ninja 1.13).
The protocol layer is also covered by the host unit test (`tests/olt_proto_test.c` — passing).
| Target | Result | Flash | RAM | Notes |
|---|---|---:|---:|---|
| `rpi_pico` | ✅ builds | 39.9 KB | 14.0 KB | full app, BLE off |
| `rpi_pico/rp2040/w` | ✅ builds | 68.1 KB | 20.2 KB | full app + BLE (see caveat) |
| `nucleo_f401re` | ✅ builds | 37.4 KB | 15.1 KB | STM32 stub, GPIO bit-bang IR backend |
| `nrf52840dk` | ✅ builds | 137.7 KB | 28.3 KB | BLE validation (real controller) |
| `esp32_devkitc_wroom` | not built here | — | — | xtensa toolchain not installed; overlay uses the verified pattern |
All application sources compile cleanly (no warnings) once the overlays use `gpio-leds` /
`gpio-keys` wrappers (bare `gpios`-only nodes don't get the GPIO cell macros) and the ADC node
is named exactly `zephyr,user`.
**Pico W BLE caveat (verified against v3.7.0 *and* mainline):** the `rpi_pico/rp2040/w` image
links with the BLE stack, but Zephyr exposes **no Bluetooth HCI transport for the Pico W**. Its
CYW43439 BT is tunneled over the shared Wi-Fi gSPI bus (there is no BT UART), and Zephyr has no
CYW43-BT-over-gSPI HCI driver — the in-tree Infineon BT bindings are all UART-based
(`infineon,bt-hci-uart`, `infineon,cyw208xx-hci`, `infineon,bless-hci`); `main`'s
`rpi_pico_rp2040_w.dts` only adds an `infineon,cyw43-gpio` node (Wi-Fi/GPIO), no `bt-hci`. So
`bt_enable()` returns `-ENODEV` at runtime and BLE does not advertise; the rest of the firmware
runs normally (handled gracefully in `ble_nus_init`). The identical `ble_nus.c` builds and
links against a real controller on `nrf52840dk`, confirming the BLE code is correct — only the
Pico W transport is missing from Zephyr. Getting it would require a custom out-of-tree HCI
driver tunneling HCI over the cyw43 gSPI (what the Pico SDK + BTstack do). It will otherwise
work unchanged on any radio board with a controller, e.g. ESP32 (`hci_esp32`).

26
migration-prompt.md Normal file
View File

@ -0,0 +1,26 @@
Mám existující firmware v MicroPythonu. Přenes ho do Zephyr RTOS.
Postupuj podle CLAUDE.md v rootu projektu.
Nejdřív:
1. Projdi strukturu projektu.
2. Najdi MicroPython entrypointy, typicky main.py, boot.py, lib/.
3. Udělej inventuru hardware použití.
4. Vytvoř docs/migration-notes.md.
5. Navrhni migrační mapu MicroPython modulů na Zephyr moduly.
Potom:
1. Vytvoř nebo aktualizuj zephyr_app/.
2. Připrav build pro rpi_pico jako první target.
3. Přidej také výchozí overlay/config pro esp32_devkitc_wroom a nucleo_f401re, pokud není z projektu jasný jiný STM32 board.
4. Portuj hlavní logiku, GPIO, časování a periferie.
5. Používej devicetree overlays a Zephyr portable API.
6. Po větších změnách spusť git diff.
7. Pokud je dostupný Zephyr/west, spusť west build.
8. Pokud Zephyr není instalovaný, napiš přesné setup kroky do README.
Důležité:
- nemaž původní MicroPython kód
- neflashuj hardware
- nedělej git push
- nehádej nejasné piny; zapiš je jako TODO do docs/migration-notes.md

20
zephyr_app/CMakeLists.txt Normal file
View File

@ -0,0 +1,20 @@
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(openlasertag_remote)
target_sources(app PRIVATE
src/main.c
src/board_io.c
src/olt_proto.c
src/piezo.c
src/buttons.c
src/rgb_led.c
src/battery.c
src/ir_tx.c
src/ir_rx.c
)
target_sources_ifdef(CONFIG_APP_BLE app PRIVATE
src/ble_nus.c
)

25
zephyr_app/Kconfig Normal file
View File

@ -0,0 +1,25 @@
# Application-level Kconfig for the OpenLaserTag remote control.
# This file becomes the Kconfig root for the application and therefore must
# source the Zephyr Kconfig tree at the end.
menu "OpenLaserTag application"
config APP_BLE
bool "Enable BLE Nordic UART service"
default n
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.
the Raspberry Pi Pico W (rpi_pico/rp2040/w) or an ESP32. Leave this
disabled for the plain rpi_pico, which has no radio.
Build with: west build -b rpi_pico/rp2040/w zephyr_app -- \
-DEXTRA_CONF_FILE=overlay-ble.conf
endmenu
source "Kconfig.zephyr"

130
zephyr_app/README.md Normal file
View File

@ -0,0 +1,130 @@
# OpenLaserTag remote control — Zephyr firmware
Zephyr C port of the MicroPython OpenLaserTag remote control (the original
`main.py` + `lib/` remain in the repository root as the reference
implementation). See `docs/migration-notes.md` for the full hardware inventory,
module map and open questions.
## What it does
- Sends 24-bit OpenLaserTag (OLT) IR commands from four buttons.
- Receives and decodes OLT IR (shots and commands).
- RGB status LED, piezo feedback, periodic battery telemetry.
- Optional BLE Nordic UART for wireless command/telemetry.
## Layout
```
zephyr_app/
CMakeLists.txt prj.conf Kconfig overlay-ble.conf
src/
main.c app_config.h board_io.*
olt_proto.* piezo.* buttons.* rgb_led.*
battery.* ir_tx.* ir_rx.* ble_nus.*
boards/
rpi_pico.overlay esp32_devkitc_wroom.overlay nucleo_f401re.overlay
tests/
olt_proto_test.c # host unit test for the protocol layer
```
## Prerequisites
This repository does **not** contain a Zephyr installation. Set up Zephyr +
the SDK once (see the Zephyr "Getting Started" guide), e.g.:
```bash
pip install west
west init ~/zephyrproject
cd ~/zephyrproject && west update && west zephyr-export
west sdk install
# then point west at this app's parent or run from here:
source ~/zephyrproject/zephyr/zephyr-env.sh
```
## Build
From the repository root (after `west` and `ZEPHYR_BASE` are available).
Verified with **Zephyr v3.7.0** + **Zephyr SDK 0.16.8** (`rpi_pico`,
`rpi_pico/rp2040/w`, `nucleo_f401re`, and `nrf52840dk` for BLE all build cleanly;
see docs/migration-notes.md → Build status).
### Primary target — Raspberry Pi Pico W
The BLE Nordic UART service is compiled in automatically by
`boards/rpi_pico_rp2040_w.conf`:
```bash
west build -b rpi_pico/rp2040/w zephyr_app
# Clean rebuild
west build -p always -b rpi_pico/rp2040/w zephyr_app
```
> **BLE runtime caveat (verified on Zephyr v3.7.0):** the image links with the
> BLE stack, but mainline Zephyr has no Bluetooth HCI transport for the Pico W
> (its CYW43 BT shares the Wi-Fi gSPI bus; the in-tree AIROC driver is UART-only).
> So `bt_enable()` fails at runtime and BLE does not advertise — the rest of the
> firmware runs normally. The same `ble_nus.c` is verified working on a board
> with a controller (`nrf52840dk`). See docs/migration-notes.md → Build status.
> For a lean image without the dormant BT stack, build the plain `rpi_pico`.
### Plain Pico (no radio)
```bash
west build -b rpi_pico zephyr_app
```
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
```
### Other (stub) boards
The ESP32 and STM32 overlays carry placeholder pin maps — verify them before
use (see migration-notes.md):
```bash
west build -b esp32_devkitc_wroom zephyr_app
west build -b nucleo_f401re zephyr_app
```
## Host test (no hardware)
The protocol layer is plain C and can be tested on a workstation:
```bash
cd zephyr_app/tests
cc -I ../src olt_proto_test.c ../src/olt_proto.c -o /tmp/olt_test && /tmp/olt_test
# -> ALL OLT PROTOCOL TESTS PASSED
```
## Flash (only when explicitly requested)
```bash
west flash
```
## Notes / known limitations
- **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

@ -0,0 +1,78 @@
/*
* ESP32-DevKitC (WROOM) overlay - STUB. Pin numbers are starting points taken
* from the MicroPython platform branch (main.py) and MUST be verified against
* your wiring before use. See docs/migration-notes.md.
*
* TODO (not yet provided here - modules degrade to no-ops with a warning):
* - RGB LED PWM: requires ESP32 LEDC channel + pinctrl setup for the three
* colour pins (rgb-red/green/blue aliases -> pwm-leds).
* - Battery ADC: requires an ADC channel for the divider input
* (zephyr_user io-channels).
*
* NOTE: main.py used GPIO23 for BOTH IR RX and IR TX on ESP32, which cannot be
* correct for simultaneous use. IR RX is tentatively moved to GPIO22 here.
*/
#include <zephyr/dt-bindings/gpio/gpio.h>
/ {
aliases {
heartbeat-led = &heartbeat_led;
piezo = &piezo_out;
ir-tx = &ir_tx_out;
ir-rx = &ir_rx_in;
};
/* Wrapped in gpio-leds/gpio-keys so DT generates the GPIO cell macros
* (drivers stay disabled; nodes are data for GPIO_DT_SPEC_GET). */
leds {
compatible = "gpio-leds";
heartbeat_led: heartbeat_led {
/* Many WROOM boards use GPIO2 for the onboard LED. TODO verify. */
gpios = <&gpio0 2 GPIO_ACTIVE_HIGH>;
label = "Heartbeat LED";
};
piezo_out: piezo_out {
gpios = <&gpio0 15 GPIO_ACTIVE_HIGH>; /* main.py piezo pin */
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";
};
};
ir_rx_keys {
compatible = "gpio-keys";
ir_rx_in: ir_rx_in {
/* TODO: confirm. GPIO23 collided with TX in the original. */
gpios = <&gpio0 22 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "IR RX";
};
};
buttons: buttons {
compatible = "gpio-keys";
btn0: button_0 {
gpios = <&gpio0 18 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "NewGame";
};
btn1: button_1 {
gpios = <&gpio0 21 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "Explode";
};
btn2: button_2 {
gpios = <&gpio0 19 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "TestShot";
};
btn3: button_3 {
gpios = <&gpio0 5 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "SensorTest";
};
};
};

View File

@ -0,0 +1,70 @@
/*
* Nucleo-F401RE overlay - STUB, used as a generic STM32 reference. All pins
* are placeholders and MUST be verified against your wiring. The original
* MicroPython firmware targeted a PyBoard ("X1"/"X2"/"X3") for STM32, so there
* is no direct Nucleo pin map - these are reasonable Arduino-header choices.
* See docs/migration-notes.md.
*
* TODO (not provided here - modules degrade to no-ops with a warning):
* - RGB LED PWM via an STM32 timer (&pwm) + pinctrl.
* - Battery ADC channel (zephyr_user io-channels).
* - BLE is unavailable on this board (no radio).
*/
#include <zephyr/dt-bindings/gpio/gpio.h>
/ {
aliases {
heartbeat-led = &heartbeat_led;
piezo = &piezo_out;
ir-tx = &ir_tx_out;
ir-rx = &ir_rx_in;
};
/* Wrapped in gpio-leds/gpio-keys so DT generates the GPIO cell macros
* (drivers stay disabled; nodes are data for GPIO_DT_SPEC_GET). */
leds {
compatible = "gpio-leds";
heartbeat_led: heartbeat_led {
gpios = <&gpioa 5 GPIO_ACTIVE_HIGH>; /* LD2 user LED */
label = "Heartbeat LED";
};
piezo_out: piezo_out {
gpios = <&gpiob 5 GPIO_ACTIVE_HIGH>; /* TODO verify */
label = "Piezo";
};
/* Plain GPIO -> src/ir_tx.c uses the software bit-bang fallback. */
ir_tx_out: ir_tx_out {
gpios = <&gpiob 4 GPIO_ACTIVE_HIGH>; /* TODO verify */
label = "IR TX";
};
};
ir_rx_keys {
compatible = "gpio-keys";
ir_rx_in: ir_rx_in {
gpios = <&gpiob 3 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; /* TODO */
label = "IR RX";
};
};
buttons: buttons {
compatible = "gpio-keys";
btn0: button_0 {
gpios = <&gpioa 8 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "NewGame";
};
btn1: button_1 {
gpios = <&gpioa 9 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "Explode";
};
btn2: button_2 {
gpios = <&gpioa 10 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "TestShot";
};
btn3: button_3 {
gpios = <&gpiob 6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "SensorTest";
};
};
};

View File

@ -0,0 +1,186 @@
/*
* Raspberry Pi Pico (RP2040) overlay for the OpenLaserTag remote control.
*
* 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, 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)
*
* BLE: the plain `rpi_pico` board has no radio. Use the Pico W
* (`rpi_pico/rp2040/w`) for the Bluetooth feature. See migration-notes.md.
*/
#include <zephyr/dt-bindings/gpio/gpio.h>
#include <zephyr/dt-bindings/pwm/pwm.h>
#include <zephyr/dt-bindings/adc/adc.h>
#include <dt-bindings/pinctrl/rpi-pico-rp2040-pinctrl.h>
/ {
aliases {
heartbeat-led = &heartbeat_led;
piezo = &piezo_out;
ir-tx = &ir_tx_pwm;
ir-rx = &ir_rx_in;
rgb-red = &rgb_red;
rgb-green = &rgb_green;
rgb-blue = &rgb_blue;
};
/*
* Pins driven directly with the GPIO API are wrapped in gpio-leds /
* gpio-keys nodes so devicetree generates the GPIO cell macros. The
* matching drivers (CONFIG_LED_GPIO / CONFIG_INPUT) are left disabled,
* so these nodes are pure data for GPIO_DT_SPEC_GET and nobody claims
* the pins.
*/
/*
* 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_leds: heartbeat-leds {
compatible = "gpio-leds";
heartbeat_led: heartbeat_led {
gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>;
label = "Heartbeat LED";
};
};
/* Piezo buzzer: active-high GPIO toggled for beeps. */
piezo_leds: piezo-leds {
compatible = "gpio-leds";
piezo_out: piezo_out {
gpios = <&gpio0 15 GPIO_ACTIVE_HIGH>;
label = "Piezo";
};
};
/* IR receiver (demodulated output of TSOP4856), active-low, pull-up. */
ir_rx_keys: ir-rx-keys {
compatible = "gpio-keys";
ir_rx_in: ir_rx_in {
gpios = <&gpio0 16 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "IR RX";
};
};
/* Four game buttons, active-low with pull-ups. Index order matches
* BUTTON_CMD_MAP in app_config.h (GPIO18,21,20,19). */
buttons: buttons {
compatible = "gpio-keys";
btn0: button_0 {
gpios = <&gpio0 18 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "NewGame";
};
btn1: button_1 {
gpios = <&gpio0 21 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "Explode";
};
btn2: button_2 {
gpios = <&gpio0 20 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "TestShot";
};
btn3: button_3 {
gpios = <&gpio0 19 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
label = "SensorTest";
};
};
/*
* 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 {
pwms = <&pwm 6 PWM_HZ(100) PWM_POLARITY_NORMAL>;
label = "RGB Red";
};
rgb_green: rgb_green {
pwms = <&pwm 10 PWM_HZ(100) PWM_POLARITY_NORMAL>;
label = "RGB Green";
};
rgb_blue: rgb_blue {
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).
* The node must be named exactly "zephyr,user" so Zephyr exposes its
* arbitrary properties; it is reached via DT_PATH(zephyr_user).
*/
zephyr,user {
io-channels = <&adc 2>;
};
};
/*
* 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_default: pwm_default {
group1 {
pinmux = <PWM_3A_P22>, <PWM_5A_P26>, <PWM_5B_P27>,
<PWM_0B_P17>;
};
};
};
&pwm {
status = "okay";
pinctrl-0 = <&pwm_default>;
pinctrl-names = "default";
/*
* 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 {
status = "okay";
#address-cells = <1>;
#size-cells = <0>;
channel@2 {
reg = <2>;
zephyr,gain = "ADC_GAIN_1";
zephyr,reference = "ADC_REF_INTERNAL";
zephyr,acquisition-time = <ADC_ACQ_TIME_DEFAULT>;
zephyr,resolution = <12>;
};
};

View File

@ -0,0 +1,26 @@
# Board-specific config, auto-merged when building for the Raspberry Pi Pico W
# (the primary target):
#
# west build -b rpi_pico/rp2040/w zephyr_app
#
# The Pico W carries a CYW43439 (Wi-Fi + Bluetooth 5.2 LE), so the BLE Nordic
# UART service is compiled in and the firmware is BLE-ready.
#
# IMPORTANT - runtime caveat (verified against Zephyr v3.7.0): mainline Zephyr
# does NOT yet expose a Bluetooth HCI transport for the Pico W. Its BT shares
# the CYW43 gSPI bus with Wi-Fi (there is no BT UART), and the in-tree AIROC
# driver (h4_ifx_cyw43xxx) is UART/H4 only. The image therefore links, but
# bt_enable() returns -ENODEV at runtime and BLE does not advertise; the rest
# of the firmware runs normally (ble_nus_init failure is handled gracefully).
# The same ble_nus.c is verified working on a board with a controller
# (nrf52840dk). Once Zephyr gains a CYW43 BT-over-gSPI HCI driver and a
# chosen zephyr,bt-hci node for this board, BLE will work unchanged.
#
# To build a lean image without the (currently dormant) BT stack, set
# CONFIG_APP_BLE=n below or build the plain `rpi_pico` board.
CONFIG_APP_BLE=y
CONFIG_BT_DEVICE_NAME="OpenLaserTag"
CONFIG_BT_DEVICE_NAME_MAX=32
CONFIG_BT_DEVICE_APPEARANCE=128

View File

@ -0,0 +1,17 @@
# 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
# 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

37
zephyr_app/prj.conf Normal file
View File

@ -0,0 +1,37 @@
# OpenLaserTag remote control - base application config.
# Peripheral pin mapping lives in the per-board overlays under boards/.
# BLE is gated behind CONFIG_APP_BLE (see Kconfig + overlay-ble.conf) because
# the plain rpi_pico board has no radio.
# Core drivers used on every board
CONFIG_GPIO=y
CONFIG_PWM=y
CONFIG_ADC=y
# Concurrency primitives (k_timer / workqueue / events replace uasyncio)
CONFIG_EVENTS=y
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048
# Logging (replaces lib/logging.py)
CONFIG_LOG=y
CONFIG_LOG_DEFAULT_LEVEL=3
CONFIG_PRINTK=y
# Stacks: main thread plus the dedicated IR TX thread need headroom.
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

@ -0,0 +1,95 @@
/*
* app_config.h - Central tunables for the OpenLaserTag Zephyr firmware.
*
* These values are ported 1:1 from the original MicroPython implementation
* (main.py, lib/rgb.py, lib/olt_lib/, lib/primitives/pushbutton.py).
* Keep hardware-independent constants here; pin mapping lives in the board
* devicetree overlays.
*/
#ifndef APP_CONFIG_H
#define APP_CONFIG_H
#include <stdint.h>
/* ---- OLT IR protocol ------------------------------------------------- */
/* OLT carrier: "My decoder chip is 56KHz" (main.py:358). */
#define OLT_CARRIER_HZ 56000U
/* IR LED carrier duty while a mark is active (lib/olt_lib/ir_tx/olt.py:20). */
#define OLT_CARRIER_DUTY_PCT 40U
/* SIRC-style mark/space timing in microseconds (ir_tx/olt.py). */
#define OLT_LEAD_MARK_US 2400U
#define OLT_LEAD_SPACE_US 600U
#define OLT_BIT_ONE_MARK_US 1200U
#define OLT_BIT_ZERO_MARK_US 600U
#define OLT_BIT_SPACE_US 600U
#define OLT_BITS 24U
/* bit23 set => command packet, clear => shot packet (main.py:177). */
#define OLT_COMMAND_MASK 0x800000U
/* ---- Button behaviour (lib/primitives/pushbutton.py) ----------------- */
#define BTN_DEBOUNCE_MS 50U
#define BTN_LONG_MS 1000U
#define BTN_DOUBLE_MS 400U
/* ---- Battery monitor (main.py:74-108) -------------------------------- */
#define BATTERY_PERIOD_MS 10000U
/* ADC full-scale reference in millivolts (RP2040 ADC, 3.3 V rail). */
#define BATTERY_VREF_MV 3300
/*
* 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
/* ---- Heartbeat (main.py:alive_check) --------------------------------- */
#define HEARTBEAT_ON_MS 100U
#define HEARTBEAT_OFF_MS 900U
/* ---- Piezo (main.py:piezo_beep / piezo_short_beep) ------------------- */
#define PIEZO_BEEP_MS 300U
#define PIEZO_SHORT_BEEP_MS 100U
/* ---- OLT command table (main.py:228-236) ----------------------------- */
#define OLT_COMMAND_END 0xE8U
enum olt_command_id {
OLT_CMD_NEW_GAME = 0, /* 0x83 0x05 0xE8 */
OLT_CMD_EXPLODE, /* 0x83 0x0B 0xE8 */
OLT_CMD_TEST_SHOT, /* 0x08 0x24 0x00 (ID8, RED, dmg 25) */
OLT_CMD_SENSOR_TEST, /* 0x83 0x15 0xE8 */
OLT_CMD_COUNT,
};
struct olt_command {
const char *name;
uint8_t tx1;
uint8_t tx2;
uint8_t tx3;
};
extern const struct olt_command olt_commands[OLT_CMD_COUNT];
/*
* Button -> command mapping, preserving main.py:371
* PINS = ((COMMANDS[0],18),(COMMANDS[1],21),(COMMANDS[2],20),(COMMANDS[3],19))
* The buttons node in the overlay lists the four buttons in this same order;
* index N of that node maps to olt_commands[BUTTON_CMD_MAP[N]].
* The array itself is defined in main.c (its only user).
*/
#endif /* APP_CONFIG_H */

124
zephyr_app/src/battery.c Normal file
View File

@ -0,0 +1,124 @@
/*
* battery.c - Battery monitor, port of main.py:battery_monitor.
*
* A k_timer fires every BATTERY_PERIOD_MS and submits a work item that samples
* the ADC, converts to millivolts, derives the alkaline-cell capacity, logs it
* and forwards a telemetry line to the registered sink (BLE in the app).
*
* Integer math is used throughout to avoid a floating-point printf dependency.
*/
#include <zephyr/kernel.h>
#include <zephyr/drivers/adc.h>
#include <zephyr/logging/log.h>
#include <stdio.h>
#include "app_config.h"
#include "battery.h"
LOG_MODULE_REGISTER(battery, LOG_LEVEL_INF);
#define ZEPHYR_USER_NODE DT_PATH(zephyr_user)
#if DT_NODE_HAS_PROP(ZEPHYR_USER_NODE, io_channels)
#define BATTERY_PRESENT 1
static const struct adc_dt_spec adc_batt =
ADC_DT_SPEC_GET_BY_IDX(ZEPHYR_USER_NODE, 0);
#else
#define BATTERY_PRESENT 0
#warning "No battery io-channel configured in overlay"
#endif
static battery_report_t report_cb;
int battery_capacity_pct(int mv)
{
/* main.py:voltage_to_capacity, in integer millivolts (one cell). */
if (mv >= 1600) {
return 100;
} else if (mv >= 1500) {
return 90 + (mv - 1500) / 10;
} else if (mv >= 1400) {
return 70 + (mv - 1400) / 5;
} else if (mv >= 1300) {
return 40 + (mv - 1300) * 3 / 10;
} else if (mv >= 1200) {
return 10 + (mv - 1200) * 3 / 10;
} else if (mv >= 1000) {
return (mv - 1000) / 20;
}
return 0;
}
#if BATTERY_PRESENT
static void battery_sample(struct k_work *work)
{
ARG_UNUSED(work);
int16_t raw = 0;
struct adc_sequence seq = {
.buffer = &raw,
.buffer_size = sizeof(raw),
};
(void)adc_sequence_init_dt(&adc_batt, &seq);
int ret = adc_read_dt(&adc_batt, &seq);
if (ret) {
LOG_WRN("adc_read failed: %d", ret);
return;
}
int32_t mv = raw;
ret = adc_raw_to_millivolts_dt(&adc_batt, &mv);
if (ret) {
/* Fall back to the configured reference if calibration data is
* unavailable for this board. */
mv = (int32_t)raw * BATTERY_VREF_MV / 4095;
}
int cell_mv = mv * BATTERY_DIVIDER_NUM / BATTERY_DIVIDER_DEN;
int pct = battery_capacity_pct(cell_mv);
char line[64];
snprintf(line, sizeof(line), "BATTERY: Voltage: %d.%02dV, Capacity: %d%%\n",
mv / 1000, (mv % 1000) / 10, pct);
LOG_INF("Battery %d.%02dV cell %d.%02dV cap %d%%",
mv / 1000, (mv % 1000) / 10,
cell_mv / 1000, (cell_mv % 1000) / 10, pct);
if (report_cb) {
report_cb(line);
}
}
static K_WORK_DEFINE(battery_work, battery_sample);
static void battery_timer_cb(struct k_timer *t)
{
ARG_UNUSED(t);
k_work_submit(&battery_work);
}
static K_TIMER_DEFINE(battery_timer, battery_timer_cb, NULL);
#endif /* BATTERY_PRESENT */
int battery_init(battery_report_t report)
{
report_cb = report;
#if BATTERY_PRESENT
if (!adc_is_ready_dt(&adc_batt)) {
LOG_ERR("battery ADC not ready");
return -ENODEV;
}
int ret = adc_channel_setup_dt(&adc_batt);
if (ret) {
LOG_ERR("ADC channel setup failed: %d", ret);
return ret;
}
/* First sample soon, then every period (main.py uses 10 s). */
k_timer_start(&battery_timer, K_SECONDS(2), K_MSEC(BATTERY_PERIOD_MS));
LOG_INF("battery monitor started");
return 0;
#else
LOG_WRN("battery monitor not configured");
return 0;
#endif
}

17
zephyr_app/src/battery.h Normal file
View File

@ -0,0 +1,17 @@
/*
* battery.h - Periodic battery voltage / capacity monitor.
* Ported from main.py:battery_monitor + voltage_to_capacity (ADC2 / GPIO28).
*/
#ifndef BATTERY_H
#define BATTERY_H
/* Sink for a formatted telemetry line (e.g. BLE notify). May be NULL. */
typedef void (*battery_report_t)(const char *line);
/* Configure the ADC channel and start the periodic monitor timer. */
int battery_init(battery_report_t report);
/* Convert one alkaline cell's millivolts to a 0-100 capacity percentage. */
int battery_capacity_pct(int cell_mv);
#endif /* BATTERY_H */

144
zephyr_app/src/ble_nus.c Normal file
View File

@ -0,0 +1,144 @@
/*
* ble_nus.c - Nordic UART Service, port of lib/ble_uart.
*
* Implements the same GATT layout the MicroPython firmware advertised:
* service 6E400001-..., TX notify 6E400003-..., RX write 6E400002-...
* RX writes are forwarded (NUL-terminated) to the registered handler; TX
* notifications carry telemetry text. Compiled only when CONFIG_APP_BLE=y.
*/
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/conn.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>
#include <string.h>
#include "ble_nus.h"
LOG_MODULE_REGISTER(ble_nus, LOG_LEVEL_INF);
/* Nordic UART UUIDs (lib/ble_uart/__init__.py:13-21). */
#define NUS_SVC_UUID BT_UUID_128_ENCODE(0x6E400001, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9E)
#define NUS_RX_UUID BT_UUID_128_ENCODE(0x6E400002, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9E)
#define NUS_TX_UUID BT_UUID_128_ENCODE(0x6E400003, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9E)
static struct bt_uuid_128 nus_svc_uuid = BT_UUID_INIT_128(NUS_SVC_UUID);
static struct bt_uuid_128 nus_rx_uuid = BT_UUID_INIT_128(NUS_RX_UUID);
static struct bt_uuid_128 nus_tx_uuid = BT_UUID_INIT_128(NUS_TX_UUID);
#define RX_BUF_SIZE 100 /* matches ble_uart rxbuf default */
static ble_rx_cb_t user_cb;
static bool notify_enabled;
static char dev_name[32];
static ssize_t on_rx(struct bt_conn *conn, const struct bt_gatt_attr *attr,
const void *buf, uint16_t len, uint16_t offset,
uint8_t flags)
{
ARG_UNUSED(conn);
ARG_UNUSED(attr);
ARG_UNUSED(offset);
ARG_UNUSED(flags);
static char rx[RX_BUF_SIZE + 1];
uint16_t n = len < RX_BUF_SIZE ? len : RX_BUF_SIZE;
memcpy(rx, buf, n);
rx[n] = '\0';
LOG_DBG("BLE RX: %s", rx);
if (user_cb) {
user_cb(rx);
}
return len;
}
static void tx_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
ARG_UNUSED(attr);
notify_enabled = (value == BT_GATT_CCC_NOTIFY);
}
/* attrs: [0]=svc [1]=TX chrc [2]=TX value [3]=CCC [4]=RX chrc [5]=RX value */
BT_GATT_SERVICE_DEFINE(nus_svc,
BT_GATT_PRIMARY_SERVICE(&nus_svc_uuid),
BT_GATT_CHARACTERISTIC(&nus_tx_uuid.uuid, BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_NONE, NULL, NULL, NULL),
BT_GATT_CCC(tx_ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
BT_GATT_CHARACTERISTIC(&nus_rx_uuid.uuid,
BT_GATT_CHRC_WRITE | BT_GATT_CHRC_WRITE_WITHOUT_RESP,
BT_GATT_PERM_WRITE, NULL, on_rx, NULL),
);
void ble_nus_write(const char *data)
{
if (!notify_enabled) {
return;
}
/* attrs[2] is the TX characteristic value; NULL conn => all subscribers. */
(void)bt_gatt_notify(NULL, &nus_svc.attrs[2], data, strlen(data));
}
static struct bt_data ad[2];
static uint8_t ad_flags = BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR;
static int advertise(void)
{
ad[0].type = BT_DATA_FLAGS;
ad[0].data_len = sizeof(ad_flags);
ad[0].data = &ad_flags;
ad[1].type = BT_DATA_NAME_COMPLETE;
ad[1].data = (const uint8_t *)dev_name;
ad[1].data_len = strlen(dev_name);
int err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), NULL, 0);
if (err) {
LOG_ERR("advertising failed: %d", err);
}
return err;
}
static void connected(struct bt_conn *conn, uint8_t err)
{
ARG_UNUSED(conn);
if (err) {
LOG_WRN("connection failed: %d", err);
return;
}
LOG_INF("BLE connected");
}
static void disconnected(struct bt_conn *conn, uint8_t reason)
{
ARG_UNUSED(conn);
LOG_INF("BLE disconnected (0x%02x), re-advertising", reason);
advertise();
}
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
};
int ble_nus_init(const char *name, ble_rx_cb_t cb)
{
user_cb = cb;
strncpy(dev_name, name, sizeof(dev_name) - 1);
dev_name[sizeof(dev_name) - 1] = '\0';
int err = bt_enable(NULL);
if (err) {
/* Expected on the Pico W with current Zephyr: its CYW43 BT has no
* HCI transport exposed, so there is no controller. The rest of
* the firmware keeps running; BLE simply stays inactive. */
LOG_ERR("bt_enable failed (%d): no BT controller for this board; "
"continuing without BLE", err);
return err;
}
(void)bt_set_name(dev_name);
LOG_INF("BLE NUS init as \"%s\"", dev_name);
return advertise();
}

20
zephyr_app/src/ble_nus.h Normal file
View File

@ -0,0 +1,20 @@
/*
* ble_nus.h - Nordic UART Service (BLE) for command/telemetry.
* Ported from lib/ble_uart. Compiled only when CONFIG_APP_BLE=y (radio-capable
* boards such as the Pico W or ESP32).
*/
#ifndef BLE_NUS_H
#define BLE_NUS_H
#include <stddef.h>
/* Called from the BLE RX characteristic write, with a NUL-terminated string. */
typedef void (*ble_rx_cb_t)(const char *msg);
/* Start advertising as "<name>" and register the RX handler. */
int ble_nus_init(const char *name, ble_rx_cb_t cb);
/* Notify connected central(s) with telemetry text (no-op if not connected). */
void ble_nus_write(const char *data);
#endif /* BLE_NUS_H */

89
zephyr_app/src/board_io.c Normal file
View File

@ -0,0 +1,89 @@
/*
* board_io.c - Heartbeat LED and device id.
* Heartbeat replaces main.py:alive_check; device id replaces the
* machine.unique_id() handling in main.py:35-41.
*/
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/logging/log.h>
#include <stdio.h>
#include "app_config.h"
#include "board_io.h"
LOG_MODULE_REGISTER(board_io, LOG_LEVEL_INF);
#define HEARTBEAT_NODE DT_ALIAS(heartbeat_led)
static char device_id[5] = "0000";
#if DT_NODE_HAS_STATUS(HEARTBEAT_NODE, okay)
#define HEARTBEAT_PRESENT 1
static const struct gpio_dt_spec heartbeat =
GPIO_DT_SPEC_GET(HEARTBEAT_NODE, gpios);
static void hb_off_fn(struct k_work *work)
{
ARG_UNUSED(work);
(void)gpio_pin_set_dt(&heartbeat, 0);
}
static K_WORK_DELAYABLE_DEFINE(hb_off, hb_off_fn);
static void hb_timer_fn(struct k_timer *t)
{
ARG_UNUSED(t);
(void)gpio_pin_set_dt(&heartbeat, 1);
k_work_reschedule(&hb_off, K_MSEC(HEARTBEAT_ON_MS));
}
static K_TIMER_DEFINE(hb_timer, hb_timer_fn, NULL);
#endif
static void resolve_device_id(void)
{
uint8_t id[16];
ssize_t len = hwinfo_get_device_id(id, sizeof(id));
if (len >= 2) {
/* Last two bytes -> last 4 hex chars, as in main.py. */
snprintf(device_id, sizeof(device_id), "%02X%02X",
id[len - 2], id[len - 1]);
} else {
LOG_WRN("hwinfo device id unavailable (%d)", (int)len);
}
}
const char *board_device_id_str(void)
{
return device_id;
}
int board_io_init(void)
{
resolve_device_id();
LOG_INF("Device ID: %s", device_id);
#if HEARTBEAT_PRESENT
if (!gpio_is_ready_dt(&heartbeat)) {
LOG_ERR("heartbeat LED not ready");
return -ENODEV;
}
int ret = gpio_pin_configure_dt(&heartbeat, GPIO_OUTPUT_INACTIVE);
if (ret) {
LOG_ERR("heartbeat configure failed: %d", ret);
return ret;
}
#else
LOG_WRN("heartbeat LED not configured");
#endif
return 0;
}
void board_io_start_heartbeat(void)
{
#if HEARTBEAT_PRESENT
k_timer_start(&hb_timer, K_NO_WAIT,
K_MSEC(HEARTBEAT_ON_MS + HEARTBEAT_OFF_MS));
#endif
}

15
zephyr_app/src/board_io.h Normal file
View File

@ -0,0 +1,15 @@
#ifndef BOARD_IO_H
#define BOARD_IO_H
#include <stdint.h>
/* Configure the heartbeat LED and resolve the device id. */
int board_io_init(void);
/* Start the heartbeat blink (100 ms on / 900 ms off), port of alive_check. */
void board_io_start_heartbeat(void);
/* 4-hex-char device id derived from the hardware unique id (main.py:35-41). */
const char *board_device_id_str(void);
#endif /* BOARD_IO_H */

140
zephyr_app/src/buttons.c Normal file
View File

@ -0,0 +1,140 @@
/*
* buttons.c - Debounced buttons replacing lib/primitives/pushbutton.py.
*
* Each GPIO edge (re)arms a debounce work item. When the level is stable the
* logical state is compared against the previous one to emit PRESS/RELEASE,
* and long-press / double-click timers run exactly as the MicroPython
* Pushbutton did.
*/
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/logging/log.h>
#include "app_config.h"
#include "buttons.h"
LOG_MODULE_REGISTER(buttons, LOG_LEVEL_INF);
#define BUTTONS_NODE DT_NODELABEL(buttons)
struct button {
struct gpio_dt_spec spec;
struct gpio_callback gpio_cb;
struct k_work_delayable debounce;
struct k_work_delayable long_work;
struct k_work_delayable double_work;
int index;
bool pressed; /* last debounced logical state */
bool waiting_double; /* a single press is pending double-click timeout */
};
#define BTN_ENTRY(node_id) { .spec = GPIO_DT_SPEC_GET(node_id, gpios) },
static struct button buttons[] = {
#if DT_NODE_HAS_STATUS(BUTTONS_NODE, okay)
DT_FOREACH_CHILD(BUTTONS_NODE, BTN_ENTRY)
#endif
};
static btn_cb_t user_cb;
int buttons_count(void)
{
return ARRAY_SIZE(buttons);
}
static void emit(struct button *b, enum btn_event ev)
{
if (user_cb) {
user_cb(b->index, ev);
}
}
static void debounce_work(struct k_work *work)
{
struct k_work_delayable *dw = k_work_delayable_from_work(work);
struct button *b = CONTAINER_OF(dw, struct button, debounce);
bool now = gpio_pin_get_dt(&b->spec) > 0; /* logical: 1 == pressed */
if (now == b->pressed) {
return; /* bounce settled back to the same state */
}
b->pressed = now;
if (now) {
emit(b, BTN_PRESS);
k_work_reschedule(&b->long_work, K_MSEC(BTN_LONG_MS));
if (b->waiting_double) {
b->waiting_double = false;
k_work_cancel_delayable(&b->double_work);
emit(b, BTN_DOUBLE);
} else {
b->waiting_double = true;
k_work_reschedule(&b->double_work, K_MSEC(BTN_DOUBLE_MS));
}
} else {
k_work_cancel_delayable(&b->long_work);
emit(b, BTN_RELEASE);
}
}
static void long_work(struct k_work *work)
{
struct k_work_delayable *dw = k_work_delayable_from_work(work);
struct button *b = CONTAINER_OF(dw, struct button, long_work);
if (b->pressed) {
emit(b, BTN_LONG);
}
}
static void double_work(struct k_work *work)
{
struct k_work_delayable *dw = k_work_delayable_from_work(work);
struct button *b = CONTAINER_OF(dw, struct button, double_work);
b->waiting_double = false; /* timed out: it was a single click */
}
static void gpio_isr(const struct device *port, struct gpio_callback *cb,
uint32_t pins)
{
ARG_UNUSED(port);
ARG_UNUSED(pins);
struct button *b = CONTAINER_OF(cb, struct button, gpio_cb);
k_work_reschedule(&b->debounce, K_MSEC(BTN_DEBOUNCE_MS));
}
int buttons_init(btn_cb_t cb)
{
user_cb = cb;
for (int i = 0; i < (int)ARRAY_SIZE(buttons); i++) {
struct button *b = &buttons[i];
b->index = i;
if (!gpio_is_ready_dt(&b->spec)) {
LOG_ERR("button %d GPIO not ready", i);
return -ENODEV;
}
int ret = gpio_pin_configure_dt(&b->spec, GPIO_INPUT);
if (ret) {
LOG_ERR("button %d configure failed: %d", i, ret);
return ret;
}
ret = gpio_pin_interrupt_configure_dt(&b->spec,
GPIO_INT_EDGE_BOTH);
if (ret) {
LOG_ERR("button %d irq config failed: %d", i, ret);
return ret;
}
k_work_init_delayable(&b->debounce, debounce_work);
k_work_init_delayable(&b->long_work, long_work);
k_work_init_delayable(&b->double_work, double_work);
gpio_init_callback(&b->gpio_cb, gpio_isr, BIT(b->spec.pin));
gpio_add_callback(b->spec.port, &b->gpio_cb);
}
LOG_INF("%d buttons initialized", (int)ARRAY_SIZE(buttons));
return 0;
}

26
zephyr_app/src/buttons.h Normal file
View File

@ -0,0 +1,26 @@
/*
* buttons.h - Debounced game buttons with long/double detection.
* Ported from lib/primitives/pushbutton.py (debounce 50 ms, long 1000 ms,
* double 400 ms) and the button wiring in main.py:369-378.
*/
#ifndef BUTTONS_H
#define BUTTONS_H
enum btn_event {
BTN_PRESS = 0,
BTN_RELEASE,
BTN_LONG,
BTN_DOUBLE,
};
/* Callback invoked from the system workqueue (not ISR context). `index` is the
* button's position in the devicetree `buttons` node (0..N-1). */
typedef void (*btn_cb_t)(int index, enum btn_event event);
/* Configure all buttons and register the event callback. */
int buttons_init(btn_cb_t cb);
/* Number of configured buttons. */
int buttons_count(void);
#endif /* BUTTONS_H */

135
zephyr_app/src/ir_rx.c Normal file
View File

@ -0,0 +1,135 @@
/*
* ir_rx.c - OpenLaserTag IR receiver.
*
* Mirrors lib/olt_lib/ir_rx/__init__.py: a GPIO interrupt timestamps every
* edge into a buffer; the first edge arms a one-shot timer whose expiry marks
* the end of the frame. Decoding (olt_decode) then runs in a work item, off
* the interrupt path, exactly like the MicroPython asyncio callback.
*/
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/logging/log.h>
#include <string.h>
#include "app_config.h"
#include "olt_proto.h"
#include "ir_rx.h"
LOG_MODULE_REGISTER(ir_rx, LOG_LEVEL_INF);
#define IR_RX_NODE DT_ALIAS(ir_rx)
/* 24-bit frame is <= 50 edges; leave headroom plus one for overrun detect. */
#define IR_RX_MAX_EDGES 64
/*
* Frame block timeout. ir_rx/olt.py: t = int(3 + bits*1.8) + 1 for 24 bits
* => ~47 ms after the leading edge.
*/
#define IR_RX_FRAME_MS 47
#if DT_NODE_HAS_STATUS(IR_RX_NODE, okay)
#define IR_RX_PRESENT 1
static const struct gpio_dt_spec ir_rx = GPIO_DT_SPEC_GET(IR_RX_NODE, gpios);
static struct gpio_callback ir_rx_cb_data;
/* 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_cycles[IR_RX_MAX_EDGES];
static uint32_t decode_count;
static ir_rx_cb_t user_cb;
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(us_times, decode_count, &f)) {
LOG_DBG("RX 0x%06X %s", f.packet,
f.is_command ? "command" : "shot");
if (user_cb) {
user_cb(&f);
}
} else {
LOG_DBG("RX bad frame (%u edges)", decode_count);
}
}
static K_WORK_DEFINE(decode_work, decode_work_fn);
/* One-shot timer: fires once the inter-edge gap exceeds the frame window. */
static void frame_timer_fn(struct k_timer *t)
{
ARG_UNUSED(t);
decode_count = edge_count;
if (decode_count > IR_RX_MAX_EDGES) {
decode_count = IR_RX_MAX_EDGES;
}
memcpy(decode_cycles, edge_cycles, decode_count * sizeof(edge_cycles[0]));
edge_count = 0; /* ready for the next frame */
k_work_submit(&decode_work);
}
static K_TIMER_DEFINE(frame_timer, frame_timer_fn, NULL);
static void ir_rx_isr(const struct device *port, struct gpio_callback *cb,
uint32_t pins)
{
ARG_UNUSED(port);
ARG_UNUSED(cb);
ARG_UNUSED(pins);
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_cycles[edge_count++] = now;
}
}
#endif /* IR_RX_PRESENT */
int ir_rx_init(ir_rx_cb_t cb)
{
#if IR_RX_PRESENT
user_cb = cb;
if (!gpio_is_ready_dt(&ir_rx)) {
LOG_ERR("IR RX GPIO not ready");
return -ENODEV;
}
int ret = gpio_pin_configure_dt(&ir_rx, GPIO_INPUT);
if (ret) {
LOG_ERR("IR RX configure failed: %d", ret);
return ret;
}
ret = gpio_pin_interrupt_configure_dt(&ir_rx, GPIO_INT_EDGE_BOTH);
if (ret) {
LOG_ERR("IR RX irq config failed: %d", ret);
return ret;
}
gpio_init_callback(&ir_rx_cb_data, ir_rx_isr, BIT(ir_rx.pin));
gpio_add_callback(ir_rx.port, &ir_rx_cb_data);
LOG_INF("IR RX initialized");
return 0;
#else
ARG_UNUSED(cb);
LOG_WRN("IR RX alias not configured");
return 0;
#endif
}

17
zephyr_app/src/ir_rx.h Normal file
View File

@ -0,0 +1,17 @@
/*
* ir_rx.h - OpenLaserTag IR receiver.
* Ported from lib/olt_lib/ir_rx. A GPIO edge interrupt timestamps every edge;
* a one-shot timeout closes the frame and a work item decodes it.
*/
#ifndef IR_RX_H
#define IR_RX_H
#include "olt_proto.h"
/* Called from the system workqueue with a successfully decoded frame. */
typedef void (*ir_rx_cb_t)(const struct olt_frame *frame);
/* Configure the IR RX GPIO/interrupt and register the decode callback. */
int ir_rx_init(ir_rx_cb_t cb);
#endif /* IR_RX_H */

277
zephyr_app/src/ir_tx.c Normal file
View File

@ -0,0 +1,277 @@
/*
* ir_tx.c - OpenLaserTag IR transmitter.
*
* Two carrier backends are selected automatically from the `ir-tx` devicetree
* alias:
*
* - 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/logging/log.h>
#include "app_config.h"
#include "olt_proto.h"
#include "ir_tx.h"
LOG_MODULE_REGISTER(ir_tx, LOG_LEVEL_INF);
#define IR_TX_NODE DT_ALIAS(ir_tx)
#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;
uint8_t tx2;
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);
/* ================================================================== */
/* 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);
/*
* 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;
for (uint32_t i = 0; i < periods; i++) {
gpio_pin_set_dt(&ir_tx, 1);
k_busy_wait(CARRIER_ON_US);
gpio_pin_set_dt(&ir_tx, 0);
k_busy_wait(CARRIER_OFF_US);
}
}
static void space(uint32_t us)
{
gpio_pin_set_dt(&ir_tx, 0);
k_busy_wait(us);
}
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);
for (size_t i = 0; i < n; i++) {
if ((i & 1U) == 0U) {
mark(sym[i]);
} else {
space(sym[i]);
}
}
gpio_pin_set_dt(&ir_tx, 0);
}
/* 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);
ARG_UNUSED(b);
ARG_UNUSED(c);
struct ir_tx_req req;
for (;;) {
k_msgq_get(&ir_tx_q, &req, K_FOREVER);
LOG_DBG("TX 0x%02X%02X%02X", req.tx1, req.tx2, req.tx3);
transmit(&req);
k_msleep(IR_TX_GAP_MS); /* inter-frame gap for the receiver */
}
}
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 };
int ret = k_msgq_put(&ir_tx_q, &req, K_NO_WAIT);
if (ret) {
LOG_WRN("IR TX queue full, dropping 0x%02X%02X%02X",
tx1, tx2, tx3);
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 defined(IR_TX_PWM) || defined(IR_TX_GPIO)
return carrier_init();
#else
LOG_WRN("IR TX alias not configured");
return 0;
#endif
}

18
zephyr_app/src/ir_tx.h Normal file
View File

@ -0,0 +1,18 @@
/*
* ir_tx.h - OpenLaserTag IR transmitter.
* Ported from lib/olt_lib/ir_tx (LT_24 / IR). Queues a 24-bit command for a
* dedicated thread that bit-bangs the 56 kHz carrier on the `ir-tx` GPIO.
*/
#ifndef IR_TX_H
#define IR_TX_H
#include <stdint.h>
/* Initialize the IR TX GPIO and start the transmit thread. */
int ir_tx_init(void);
/* Queue a 24-bit OLT command (tx1 = most significant byte). Non-blocking;
* safe to call from thread context. Returns 0 on success. */
int ir_tx_send(uint8_t tx1, uint8_t tx2, uint8_t tx3);
#endif /* IR_TX_H */

166
zephyr_app/src/main.c Normal file
View File

@ -0,0 +1,166 @@
/*
* main.c - OpenLaserTag remote control, Zephyr port of main.py.
*
* Wiring of the event-driven subsystems (uasyncio tasks in the original):
* - buttons -> send the mapped OLT command over IR, beep, flash green
* - IR RX -> log + BLE telemetry + flash blue then back to red
* - battery -> periodic BLE telemetry
* - BLE RX -> parse bomb/led_on/led_off/hex command
* - heartbeat + piezo startup beep
*/
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "app_config.h"
#include "board_io.h"
#include "olt_proto.h"
#include "piezo.h"
#include "buttons.h"
#include "rgb_led.h"
#include "battery.h"
#include "ir_tx.h"
#include "ir_rx.h"
#if defined(CONFIG_APP_BLE)
#include "ble_nus.h"
#endif
LOG_MODULE_REGISTER(app_main, LOG_LEVEL_INF);
/* Button index -> OLT command (see app_config.h / main.py:371). */
static const enum olt_command_id BUTTON_CMD_MAP[4] = {
OLT_CMD_NEW_GAME, /* button 0 (GPIO18) */
OLT_CMD_EXPLODE, /* button 1 (GPIO21) */
OLT_CMD_TEST_SHOT, /* button 2 (GPIO20) */
OLT_CMD_SENSOR_TEST, /* button 3 (GPIO19) */
};
/* Forward telemetry to BLE when available, otherwise just log. */
static void telemetry(const char *line)
{
#if defined(CONFIG_APP_BLE)
ble_nus_write(line);
#endif
LOG_INF("%s", line);
}
/* Restore the idle (red) colour a while after an RX flash (main.py:324-326). */
static void rgb_idle_fn(struct k_work *work)
{
ARG_UNUSED(work);
rgb_red();
}
static K_WORK_DELAYABLE_DEFINE(rgb_idle, rgb_idle_fn);
static void on_button(int index, enum btn_event event)
{
if (event != BTN_PRESS) {
return; /* original acts on press; release/long/double unused */
}
if (index < 0 || index >= (int)ARRAY_SIZE(BUTTON_CMD_MAP)) {
return;
}
const struct olt_command *cmd = &olt_commands[BUTTON_CMD_MAP[index]];
LOG_INF("Button %d -> %s (0x%02X%02X%02X)", index, cmd->name,
cmd->tx1, cmd->tx2, cmd->tx3);
piezo_short_beep();
rgb_green();
ir_tx_send(cmd->tx1, cmd->tx2, cmd->tx3);
}
static void on_ir_rx(const struct olt_frame *f)
{
char line[64];
if (f->is_command) {
snprintf(line, sizeof(line), "IR RX Command 0x%06X\n", f->packet);
telemetry(line);
} else {
snprintf(line, sizeof(line), "IR RX Shot 0x%06X\n", f->packet);
telemetry(line);
snprintf(line, sizeof(line),
"Shot: player ID:%u, team %s, demage %u\n",
f->shot.player_id, olt_team_name(f->shot.team),
f->shot.damage);
telemetry(line);
}
rgb_blue();
k_work_reschedule(&rgb_idle, K_MSEC(500));
}
#if defined(CONFIG_APP_BLE)
static void on_ble_rx(const char *msg)
{
char line[64];
if (strstr(msg, "bomb")) {
LOG_INF("BLE: Bomb Explode send to IR");
const struct olt_command *ex = &olt_commands[OLT_CMD_EXPLODE];
for (int i = 0; i < 5; i++) {
ir_tx_send(ex->tx1, ex->tx2, ex->tx3);
}
piezo_beep();
rgb_red();
telemetry("BLE: Bomb Explode send to IR\n");
} else if (strstr(msg, "led_on")) {
rgb_red();
telemetry("BLE: LED ON\n");
} else if (strstr(msg, "led_off")) {
rgb_off();
telemetry("BLE: LED OFF\n");
} else {
/* Hex string -> 24-bit custom command (main.py:155-165). */
char *end = NULL;
unsigned long v = strtoul(msg, &end, 16);
if (end == msg) {
LOG_DBG("BLE: unparsable message");
return;
}
uint8_t tx1 = (v >> 16) & 0xFF;
uint8_t tx2 = (v >> 8) & 0xFF;
uint8_t tx3 = v & 0xFF;
ir_tx_send(tx1, tx2, tx3);
snprintf(line, sizeof(line), "BLE: uart recive 0x%06lX\n",
v & 0xFFFFFFUL);
telemetry(line);
}
}
#endif /* CONFIG_APP_BLE */
int main(void)
{
LOG_INF("OpenLaserTag remote control starting");
board_io_init();
piezo_init();
rgb_init();
rgb_red(); /* idle colour, as main.py sets red before the button loop */
battery_init(telemetry);
ir_tx_init();
ir_rx_init(on_ir_rx);
buttons_init(on_button);
#if defined(CONFIG_APP_BLE)
char name[32];
snprintf(name, sizeof(name), "OpenLaserTag %s", board_device_id_str());
ble_nus_init(name, on_ble_rx);
#else
LOG_INF("BLE disabled (build with overlay-ble.conf on a radio board)");
#endif
board_io_start_heartbeat();
piezo_short_beep();
LOG_INF("Init complete");
return 0;
}

108
zephyr_app/src/olt_proto.c Normal file
View File

@ -0,0 +1,108 @@
/*
* olt_proto.c - OpenLaserTag IR protocol encode/decode.
* Ported from lib/olt_lib/ir_tx/olt.py and lib/olt_lib/ir_rx/olt.py.
*/
#include "olt_proto.h"
/* Command table, ported from main.py:228-236. */
const struct olt_command olt_commands[OLT_CMD_COUNT] = {
[OLT_CMD_NEW_GAME] = { "NewGame", 0x83, 0x05, OLT_COMMAND_END },
[OLT_CMD_EXPLODE] = { "Explode", 0x83, 0x0B, OLT_COMMAND_END },
[OLT_CMD_TEST_SHOT] = { "TestShot", 0x08, 0x24, 0x00 },
[OLT_CMD_SENSOR_TEST] = { "SensorTest", 0x83, 0x15, OLT_COMMAND_END },
};
/* Damage lookup, ported from main.py:178 (DEMAGE). */
static const uint8_t damage_lut[16] = {
0, 1, 4, 5, 7, 10, 15, 17, 20, 25, 30, 35, 40, 50, 75, 100,
};
static const char *const team_names[4] = { "RED", "BLUE", "YELLOW", "GREEN" };
const char *olt_team_name(uint8_t team)
{
return team < 4 ? team_names[team] : "?";
}
/* Bit-reverse a 32-bit value (ir_tx/olt.py:rbit32). */
static uint32_t rbit32(uint32_t v)
{
v = (v & 0x0000FFFFu) << 16 | (v & 0xFFFF0000u) >> 16;
v = (v & 0x00FF00FFu) << 8 | (v & 0xFF00FF00u) >> 8;
v = (v & 0x0F0F0F0Fu) << 4 | (v & 0xF0F0F0F0u) >> 4;
v = (v & 0x33333333u) << 2 | (v & 0xCCCCCCCCu) >> 2;
return (v & 0x55555555u) << 1 | (v & 0xAAAAAAAAu) >> 1;
}
void olt_frame_from_packet(uint32_t packet, struct olt_frame *frame)
{
frame->packet = packet & 0xFFFFFFu;
frame->byte1 = (packet >> 16) & 0xFF;
frame->byte2 = (packet >> 8) & 0xFF;
frame->byte3 = packet & 0xFF;
frame->is_command = (packet & OLT_COMMAND_MASK) != 0;
/* Shot fields (main.py:312-316). Meaningful only for shot packets. */
uint32_t v = (packet >> 10) & 0x3FFF;
frame->shot.player_id = (v >> 6) & 0xFF;
frame->shot.team = (v >> 4) & 0x3;
frame->shot.damage = damage_lut[v & 0xF];
}
size_t olt_encode(uint8_t tx1, uint8_t tx2, uint8_t tx3, uint16_t *out)
{
uint32_t v = ((uint32_t)tx1 << 16) | ((uint32_t)tx2 << 8) | tx3;
v = rbit32(v);
v >>= 8; /* keep the 24 reversed bits in the low word */
size_t n = 0;
out[n++] = OLT_LEAD_MARK_US; /* carrier ON */
out[n++] = OLT_LEAD_SPACE_US; /* carrier OFF */
for (uint32_t i = 0; i < OLT_BITS; i++) {
out[n++] = (v & 1u) ? OLT_BIT_ONE_MARK_US : OLT_BIT_ZERO_MARK_US;
out[n++] = OLT_BIT_SPACE_US;
v >>= 1;
}
return n; /* == OLT_MAX_SYMBOLS */
}
bool olt_decode(const uint32_t *times, size_t nedges, struct olt_frame *frame)
{
/* Edge-count gate (ir_rx/olt.py:38). For 24-bit frames nedges == 50. */
if (nedges != 30 && nedges != 50 && nedges != 58) {
return false;
}
size_t bits = (nedges - 2) / 2;
if (bits > OLT_BITS) {
return false;
}
/* Leading 2.4 ms mark. */
uint32_t width = times[1] - times[0];
if (!(width > 1800 && width < 3000)) {
return false;
}
/* Leading 600 µs space. */
width = times[2] - times[1];
if (!(width > 350 && width < 1000)) {
return false;
}
uint32_t val = 0;
uint32_t bit = 1;
for (size_t x = 2; x <= nedges - 2; x += 2) {
if ((times[x + 1] - times[x]) > 900) { /* 1200 µs mark => 1 */
val |= bit;
}
bit <<= 1;
}
val = rbit32(val);
val >>= 8;
olt_frame_from_packet(val & 0xFFFFFFu, frame);
return true;
}

View File

@ -0,0 +1,67 @@
/*
* olt_proto.h - OpenLaserTag IR protocol (pure C, hardware independent).
*
* Ported from lib/olt_lib/ir_tx/olt.py and lib/olt_lib/ir_rx/olt.py.
* The encoder produces the µs mark/space timing sequence; the decoder turns a
* captured edge-time sequence back into a 24-bit packet.
*/
#ifndef OLT_PROTO_H
#define OLT_PROTO_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "app_config.h"
/* Max symbols emitted by olt_encode():
* lead mark + lead space + 24 * (bit mark + bit space) = 2 + 48 = 50. */
#define OLT_MAX_SYMBOLS (2U + OLT_BITS * 2U)
/* team identifiers, index into olt_team_name(). */
enum olt_team {
OLT_TEAM_RED = 0,
OLT_TEAM_BLUE,
OLT_TEAM_YELLOW,
OLT_TEAM_GREEN,
};
/* Decoded shot packet (only valid when !is_command). */
struct olt_shot {
uint8_t player_id; /* 7-bit */
uint8_t team; /* enum olt_team */
uint8_t damage; /* resolved hit-points from the damage LUT */
};
/* Decoded frame. */
struct olt_frame {
uint32_t packet; /* 24-bit raw payload */
uint8_t byte1; /* (packet >> 16) & 0xFF */
uint8_t byte2; /* (packet >> 8) & 0xFF */
uint8_t byte3; /* packet & 0xFF */
bool is_command; /* bit23 set */
struct olt_shot shot;
};
const char *olt_team_name(uint8_t team);
/*
* Encode a 24-bit command (tx1=MSB byte) into an on/off duration sequence in
* microseconds. out[0] is the first carrier-ON (mark) duration, out[1] the
* following carrier-OFF (space), alternating. Returns the number of entries
* written (always OLT_MAX_SYMBOLS). out must hold at least OLT_MAX_SYMBOLS.
*/
size_t olt_encode(uint8_t tx1, uint8_t tx2, uint8_t tx3, uint16_t *out);
/*
* Decode an IR frame from absolute edge timestamps (microseconds).
* `times` holds `nedges` timestamps captured on every signal edge, oldest
* first (same model as lib/olt_lib/ir_rx/__init__.py). Returns true and fills
* `frame` on a valid 24-bit packet; returns false on a malformed frame.
*/
bool olt_decode(const uint32_t *times, size_t nedges, struct olt_frame *frame);
/* Fill `frame` (shot fields too) from an already-known 24-bit packet. */
void olt_frame_from_packet(uint32_t packet, struct olt_frame *frame);
#endif /* OLT_PROTO_H */

72
zephyr_app/src/piezo.c Normal file
View File

@ -0,0 +1,72 @@
/*
* piezo.c - Non-blocking piezo buzzer.
* The MicroPython version awaited inside the beep coroutine; here we turn the
* pin on and let a delayed work item turn it off, so callers never block.
*/
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/logging/log.h>
#include "app_config.h"
#include "piezo.h"
LOG_MODULE_REGISTER(piezo, LOG_LEVEL_INF);
#define PIEZO_NODE DT_ALIAS(piezo)
#if DT_NODE_HAS_STATUS(PIEZO_NODE, okay)
static const struct gpio_dt_spec piezo = GPIO_DT_SPEC_GET(PIEZO_NODE, gpios);
static bool ready;
static void piezo_off_work(struct k_work *work)
{
ARG_UNUSED(work);
(void)gpio_pin_set_dt(&piezo, 0);
}
static K_WORK_DELAYABLE_DEFINE(piezo_off, piezo_off_work);
#endif
int piezo_init(void)
{
#if DT_NODE_HAS_STATUS(PIEZO_NODE, okay)
if (!gpio_is_ready_dt(&piezo)) {
LOG_ERR("piezo GPIO not ready");
return -ENODEV;
}
int ret = gpio_pin_configure_dt(&piezo, GPIO_OUTPUT_INACTIVE);
if (ret) {
LOG_ERR("piezo configure failed: %d", ret);
return ret;
}
ready = true;
LOG_INF("piezo initialized");
return 0;
#else
LOG_WRN("piezo alias not configured");
return 0;
#endif
}
void piezo_beep_ms(uint32_t ms)
{
#if DT_NODE_HAS_STATUS(PIEZO_NODE, okay)
if (!ready) {
return;
}
(void)gpio_pin_set_dt(&piezo, 1);
/* Reschedule: the latest request wins, matching overlapping beeps. */
(void)k_work_reschedule(&piezo_off, K_MSEC(ms));
#else
ARG_UNUSED(ms);
#endif
}
void piezo_beep(void)
{
piezo_beep_ms(PIEZO_BEEP_MS);
}
void piezo_short_beep(void)
{
piezo_beep_ms(PIEZO_SHORT_BEEP_MS);
}

20
zephyr_app/src/piezo.h Normal file
View File

@ -0,0 +1,20 @@
/*
* piezo.h - Non-blocking piezo buzzer control.
* Ported from main.py:piezo_beep / piezo_short_beep (GPIO15, active-high).
*/
#ifndef PIEZO_H
#define PIEZO_H
#include <stdint.h>
/* Configure the piezo GPIO. Returns 0 on success, negative errno otherwise. */
int piezo_init(void);
/* Drive the piezo high and schedule it off after `ms` (non-blocking). */
void piezo_beep_ms(uint32_t ms);
/* Convenience wrappers matching the MicroPython helpers. */
void piezo_beep(void); /* PIEZO_BEEP_MS */
void piezo_short_beep(void); /* PIEZO_SHORT_BEEP_MS */
#endif /* PIEZO_H */

114
zephyr_app/src/rgb_led.c Normal file
View File

@ -0,0 +1,114 @@
/*
* 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>
#include <zephyr/logging/log.h>
#include "rgb_led.h"
LOG_MODULE_REGISTER(rgb_led, LOG_LEVEL_INF);
#define RGB_R_NODE DT_ALIAS(rgb_red)
#define RGB_G_NODE DT_ALIAS(rgb_green)
#define RGB_B_NODE DT_ALIAS(rgb_blue)
#if DT_NODE_HAS_STATUS(RGB_R_NODE, okay) && \
DT_NODE_HAS_STATUS(RGB_G_NODE, okay) && \
DT_NODE_HAS_STATUS(RGB_B_NODE, okay)
#define RGB_PRESENT 1
static const struct pwm_dt_spec led_r = PWM_DT_SPEC_GET(RGB_R_NODE);
static const struct pwm_dt_spec led_g = PWM_DT_SPEC_GET(RGB_G_NODE);
static const struct pwm_dt_spec led_b = PWM_DT_SPEC_GET(RGB_B_NODE);
#else
#define RGB_PRESENT 0
#warning "RGB aliases (rgb-red/green/blue) not found in overlay"
#endif
/* "White" preset brightness, ~0.8 on the original 0.0-1.0 scale. */
#define RGB_WHITE_LEVEL 204U
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, uint8_t level)
{
/* 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);
}
}
#endif
int rgb_init(void)
{
#if RGB_PRESENT
if (!pwm_is_ready_dt(&led_r) || !pwm_is_ready_dt(&led_g) ||
!pwm_is_ready_dt(&led_b)) {
LOG_ERR("RGB PWM not ready");
return -ENODEV;
}
rgb_off();
LOG_INF("RGB LED initialized");
return 0;
#else
LOG_WRN("RGB LED not configured");
return 0;
#endif
}
void rgb_set(uint8_t r, uint8_t g, uint8_t b)
{
#if RGB_PRESENT
set_channel(&led_r, r);
set_channel(&led_g, g);
set_channel(&led_b, b);
#else
ARG_UNUSED(r);
ARG_UNUSED(g);
ARG_UNUSED(b);
#endif
}
void rgb_set_power(uint8_t pct)
{
rgb_pwr = pct > 100U ? 100U : pct;
}
uint8_t rgb_get_power(void)
{
return rgb_pwr;
}
void rgb_off(void)
{
rgb_set(0, 0, 0);
}
void rgb_red(void)
{
rgb_set(255, 0, 0);
}
void rgb_green(void)
{
rgb_set(0, 255, 0);
}
void rgb_blue(void)
{
rgb_set(0, 0, 255);
}
void rgb_white(void)
{
rgb_set(RGB_WHITE_LEVEL, RGB_WHITE_LEVEL, RGB_WHITE_LEVEL);
}

30
zephyr_app/src/rgb_led.h Normal file
View File

@ -0,0 +1,30 @@
/*
* rgb_led.h - RGB status LED via PWM.
*
* Ported from lib/rgb.py (GPIO22/26/27, 100 Hz, active-high, global power
* 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-255 each); scaled by the current power level. */
void rgb_set(uint8_t r, uint8_t g, uint8_t b);
/* 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);
void rgb_red(void);
void rgb_green(void);
void rgb_blue(void);
void rgb_white(void);
#endif /* RGB_LED_H */

View File

@ -0,0 +1,91 @@
/*
* Host unit test for olt_proto.c (no Zephyr dependency).
*
* Build & run on a workstation:
* cc -I ../src olt_proto_test.c ../src/olt_proto.c -o /tmp/olt_test && /tmp/olt_test
*
* Verifies encode timing, encode->decode round-trips, and shot decoding.
*/
#include <stdio.h>
#include <string.h>
#include "olt_proto.h"
static int failures;
#define CHECK(cond, ...) \
do { \
if (!(cond)) { \
printf("FAIL: "); \
printf(__VA_ARGS__); \
printf("\n"); \
failures++; \
} \
} while (0)
/* Build the absolute edge-time array the RX would capture from an encoded
* frame (see decode model in olt_proto.c / ir_rx). Returns nedges. */
static size_t synth_edges(const uint16_t *sym, uint32_t *times)
{
/* 50 symbols produce 50 usable edge timestamps (0..49). */
times[0] = 0;
for (size_t i = 0; i < OLT_MAX_SYMBOLS - 1; i++) {
times[i + 1] = times[i] + sym[i];
}
return OLT_MAX_SYMBOLS; /* 50 */
}
static void test_roundtrip(uint8_t b1, uint8_t b2, uint8_t b3)
{
uint16_t sym[OLT_MAX_SYMBOLS];
uint32_t times[OLT_MAX_SYMBOLS + 2];
struct olt_frame f;
size_t n = olt_encode(b1, b2, b3, sym);
CHECK(n == OLT_MAX_SYMBOLS, "encode count %zu", n);
CHECK(sym[0] == OLT_LEAD_MARK_US, "lead mark %u", sym[0]);
CHECK(sym[1] == OLT_LEAD_SPACE_US, "lead space %u", sym[1]);
size_t ne = synth_edges(sym, times);
bool ok = olt_decode(times, ne, &f);
CHECK(ok, "decode 0x%02X%02X%02X failed", b1, b2, b3);
uint32_t expect = ((uint32_t)b1 << 16) | ((uint32_t)b2 << 8) | b3;
CHECK(f.packet == expect, "roundtrip got 0x%06X want 0x%06X",
f.packet, expect);
CHECK(f.byte1 == b1 && f.byte2 == b2 && f.byte3 == b3,
"bytes %02X %02X %02X", f.byte1, f.byte2, f.byte3);
}
int main(void)
{
/* Round-trip the standard command set. */
for (int i = 0; i < OLT_CMD_COUNT; i++) {
const struct olt_command *c = &olt_commands[i];
test_roundtrip(c->tx1, c->tx2, c->tx3);
}
/* A spread of arbitrary 24-bit values. */
test_roundtrip(0x00, 0x00, 0x00);
test_roundtrip(0xFF, 0xFF, 0xFF);
test_roundtrip(0x12, 0x34, 0x56);
test_roundtrip(0xAA, 0x55, 0xA5);
/* Command vs shot classification. */
struct olt_frame f;
olt_frame_from_packet(0x830BE8, &f); /* Explode */
CHECK(f.is_command, "Explode should be a command packet");
/* TestShot 0x082400: id 8, RED, damage 25 (main.py:234 comment). */
olt_frame_from_packet(0x082400, &f);
CHECK(!f.is_command, "TestShot should be a shot packet");
CHECK(f.shot.player_id == 8, "player id %u", f.shot.player_id);
CHECK(f.shot.team == OLT_TEAM_RED, "team %s", olt_team_name(f.shot.team));
CHECK(f.shot.damage == 25, "damage %u", f.shot.damage);
if (failures == 0) {
printf("ALL OLT PROTOCOL TESTS PASSED\n");
return 0;
}
printf("%d FAILURE(S)\n", failures);
return 1;
}