diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..edfa194 --- /dev/null +++ b/.claude/settings.json @@ -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 *)" + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fec9b3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +*.pyc +__pycache__/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f0b533e --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/docs/migration-notes.md b/docs/migration-notes.md new file mode 100644 index 0000000..ebbe8d9 --- /dev/null +++ b/docs/migration-notes.md @@ -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`). diff --git a/migration-prompt.md b/migration-prompt.md new file mode 100644 index 0000000..e0f6b92 --- /dev/null +++ b/migration-prompt.md @@ -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 diff --git a/zephyr_app/CMakeLists.txt b/zephyr_app/CMakeLists.txt new file mode 100644 index 0000000..cd2001b --- /dev/null +++ b/zephyr_app/CMakeLists.txt @@ -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 +) diff --git a/zephyr_app/Kconfig b/zephyr_app/Kconfig new file mode 100644 index 0000000..f61502f --- /dev/null +++ b/zephyr_app/Kconfig @@ -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" diff --git a/zephyr_app/README.md b/zephyr_app/README.md new file mode 100644 index 0000000..efa5d15 --- /dev/null +++ b/zephyr_app/README.md @@ -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). diff --git a/zephyr_app/boards/esp32_devkitc_wroom.overlay b/zephyr_app/boards/esp32_devkitc_wroom.overlay new file mode 100644 index 0000000..ce5b33b --- /dev/null +++ b/zephyr_app/boards/esp32_devkitc_wroom.overlay @@ -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 + +/ { + 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"; + }; + }; +}; diff --git a/zephyr_app/boards/nucleo_f401re.overlay b/zephyr_app/boards/nucleo_f401re.overlay new file mode 100644 index 0000000..4c36d0e --- /dev/null +++ b/zephyr_app/boards/nucleo_f401re.overlay @@ -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 + +/ { + 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"; + }; + }; +}; diff --git a/zephyr_app/boards/rpi_pico.overlay b/zephyr_app/boards/rpi_pico.overlay new file mode 100644 index 0000000..6e69e20 --- /dev/null +++ b/zephyr_app/boards/rpi_pico.overlay @@ -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 +#include +#include +#include + +/ { + 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 { + 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 = ; + zephyr,resolution = <12>; + }; +}; diff --git a/zephyr_app/boards/rpi_pico_rp2040_w.conf b/zephyr_app/boards/rpi_pico_rp2040_w.conf new file mode 100644 index 0000000..f40b790 --- /dev/null +++ b/zephyr_app/boards/rpi_pico_rp2040_w.conf @@ -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 diff --git a/zephyr_app/overlay-ble.conf b/zephyr_app/overlay-ble.conf new file mode 100644 index 0000000..7279753 --- /dev/null +++ b/zephyr_app/overlay-ble.conf @@ -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 diff --git a/zephyr_app/prj.conf b/zephyr_app/prj.conf new file mode 100644 index 0000000..7182f2d --- /dev/null +++ b/zephyr_app/prj.conf @@ -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 diff --git a/zephyr_app/src/app_config.h b/zephyr_app/src/app_config.h new file mode 100644 index 0000000..6c9c9f5 --- /dev/null +++ b/zephyr_app/src/app_config.h @@ -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 + +/* ---- 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 */ diff --git a/zephyr_app/src/battery.c b/zephyr_app/src/battery.c new file mode 100644 index 0000000..965abab --- /dev/null +++ b/zephyr_app/src/battery.c @@ -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 +#include +#include +#include + +#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 +} diff --git a/zephyr_app/src/battery.h b/zephyr_app/src/battery.h new file mode 100644 index 0000000..cc439ec --- /dev/null +++ b/zephyr_app/src/battery.h @@ -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 */ diff --git a/zephyr_app/src/ble_nus.c b/zephyr_app/src/ble_nus.c new file mode 100644 index 0000000..f9fe9e3 --- /dev/null +++ b/zephyr_app/src/ble_nus.c @@ -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 +#include +#include +#include +#include +#include +#include + +#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(); +} diff --git a/zephyr_app/src/ble_nus.h b/zephyr_app/src/ble_nus.h new file mode 100644 index 0000000..666fa96 --- /dev/null +++ b/zephyr_app/src/ble_nus.h @@ -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 + +/* Called from the BLE RX characteristic write, with a NUL-terminated string. */ +typedef void (*ble_rx_cb_t)(const char *msg); + +/* Start advertising as "" 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 */ diff --git a/zephyr_app/src/board_io.c b/zephyr_app/src/board_io.c new file mode 100644 index 0000000..b8528fe --- /dev/null +++ b/zephyr_app/src/board_io.c @@ -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 +#include +#include +#include +#include +#include + +#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 +} diff --git a/zephyr_app/src/board_io.h b/zephyr_app/src/board_io.h new file mode 100644 index 0000000..da5b99f --- /dev/null +++ b/zephyr_app/src/board_io.h @@ -0,0 +1,15 @@ +#ifndef BOARD_IO_H +#define BOARD_IO_H + +#include + +/* 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 */ diff --git a/zephyr_app/src/buttons.c b/zephyr_app/src/buttons.c new file mode 100644 index 0000000..e322c15 --- /dev/null +++ b/zephyr_app/src/buttons.c @@ -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 +#include +#include + +#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; +} diff --git a/zephyr_app/src/buttons.h b/zephyr_app/src/buttons.h new file mode 100644 index 0000000..59c236d --- /dev/null +++ b/zephyr_app/src/buttons.h @@ -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 */ diff --git a/zephyr_app/src/ir_rx.c b/zephyr_app/src/ir_rx.c new file mode 100644 index 0000000..e62098e --- /dev/null +++ b/zephyr_app/src/ir_rx.c @@ -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 +#include +#include +#include + +#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 +} diff --git a/zephyr_app/src/ir_rx.h b/zephyr_app/src/ir_rx.h new file mode 100644 index 0000000..5b686cf --- /dev/null +++ b/zephyr_app/src/ir_rx.h @@ -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 */ diff --git a/zephyr_app/src/ir_tx.c b/zephyr_app/src/ir_tx.c new file mode 100644 index 0000000..c284918 --- /dev/null +++ b/zephyr_app/src/ir_tx.c @@ -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 +#include + +#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 + +static const struct pwm_dt_spec carrier = PWM_DT_SPEC_GET(IR_TX_NODE); +static uint32_t mark_pulse; /* PWM pulse width (ns) for a "mark" (40 % duty) */ + +enum tx_state { TX_IDLE, TX_FRAME, TX_GAP }; +static volatile enum tx_state state = TX_IDLE; +static struct k_spinlock lock; + +static uint16_t sym[OLT_MAX_SYMBOLS]; +static size_t sym_n; +static size_t sym_idx; +static struct k_timer tx_timer; + +/* Even symbol index = mark (carrier on), odd = space (carrier off). */ +static inline void carrier_apply(size_t idx) +{ + (void)pwm_set_pulse_dt(&carrier, (idx & 1U) ? 0U : mark_pulse); +} + +static inline void carrier_idle(void) +{ + (void)pwm_set_pulse_dt(&carrier, 0); +} + +/* Pop the next queued frame into sym[]. Returns true if one was loaded. */ +static bool load_next(void) +{ + struct ir_tx_req r; + + if (k_msgq_get(&ir_tx_q, &r, K_NO_WAIT) != 0) { + return false; + } + sym_n = olt_encode(r.tx1, r.tx2, r.tx3, sym); + sym_idx = 0; + return true; +} + +/* Apply the current symbol and arm the timer for its duration. */ +static void apply_and_arm(void) +{ + carrier_apply(sym_idx); + k_timer_start(&tx_timer, K_USEC(sym[sym_idx]), K_NO_WAIT); +} + +/* Runs in the system-clock ISR at each symbol/gap boundary. */ +static void tx_timer_fn(struct k_timer *t) +{ + ARG_UNUSED(t); + + switch (state) { + case TX_FRAME: + if (++sym_idx < sym_n) { + apply_and_arm(); + break; + } + /* Frame finished. */ + carrier_idle(); + if (k_msgq_num_used_get(&ir_tx_q) > 0) { + state = TX_GAP; + k_timer_start(&tx_timer, K_MSEC(IR_TX_GAP_MS), K_NO_WAIT); + } else { + state = TX_IDLE; + } + break; + + case TX_GAP: + if (load_next()) { + state = TX_FRAME; + apply_and_arm(); + } else { + state = TX_IDLE; + } + break; + + default: + break; + } +} + +/* Kick the state machine if it is idle (called from thread context). */ +static void ir_tx_kick(void) +{ + k_spinlock_key_t key = k_spin_lock(&lock); + + if (state == TX_IDLE && load_next()) { + state = TX_FRAME; + apply_and_arm(); + } + k_spin_unlock(&lock, key); +} + +static int carrier_init(void) +{ + if (!pwm_is_ready_dt(&carrier)) { + LOG_ERR("IR TX PWM not ready"); + return -ENODEV; + } + mark_pulse = (uint32_t)(((uint64_t)carrier.period * + OLT_CARRIER_DUTY_PCT) / 100U); + k_timer_init(&tx_timer, tx_timer_fn, NULL); + carrier_idle(); + LOG_INF("IR TX initialized (non-blocking hardware PWM carrier)"); + return 0; +} + +/* ================================================================== */ +/* GPIO backend: software bit-bang fallback (blocking, on a thread) */ +/* ================================================================== */ +#elif defined(IR_TX_GPIO) +#include + +static const struct gpio_dt_spec ir_tx = GPIO_DT_SPEC_GET(IR_TX_NODE, gpios); + +/* + * 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 +} diff --git a/zephyr_app/src/ir_tx.h b/zephyr_app/src/ir_tx.h new file mode 100644 index 0000000..4dfdae9 --- /dev/null +++ b/zephyr_app/src/ir_tx.h @@ -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 + +/* 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 */ diff --git a/zephyr_app/src/main.c b/zephyr_app/src/main.c new file mode 100644 index 0000000..f75eb0c --- /dev/null +++ b/zephyr_app/src/main.c @@ -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 +#include +#include +#include +#include + +#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; +} diff --git a/zephyr_app/src/olt_proto.c b/zephyr_app/src/olt_proto.c new file mode 100644 index 0000000..196b883 --- /dev/null +++ b/zephyr_app/src/olt_proto.c @@ -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; +} diff --git a/zephyr_app/src/olt_proto.h b/zephyr_app/src/olt_proto.h new file mode 100644 index 0000000..ce7c190 --- /dev/null +++ b/zephyr_app/src/olt_proto.h @@ -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 +#include +#include + +#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 */ diff --git a/zephyr_app/src/piezo.c b/zephyr_app/src/piezo.c new file mode 100644 index 0000000..5f0ed60 --- /dev/null +++ b/zephyr_app/src/piezo.c @@ -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 +#include +#include + +#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); +} diff --git a/zephyr_app/src/piezo.h b/zephyr_app/src/piezo.h new file mode 100644 index 0000000..c2a165c --- /dev/null +++ b/zephyr_app/src/piezo.h @@ -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 + +/* 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 */ diff --git a/zephyr_app/src/rgb_led.c b/zephyr_app/src/rgb_led.c new file mode 100644 index 0000000..c58c84f --- /dev/null +++ b/zephyr_app/src/rgb_led.c @@ -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 +#include +#include + +#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); +} diff --git a/zephyr_app/src/rgb_led.h b/zephyr_app/src/rgb_led.h new file mode 100644 index 0000000..390f5d0 --- /dev/null +++ b/zephyr_app/src/rgb_led.h @@ -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 + +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 */ diff --git a/zephyr_app/tests/olt_proto_test.c b/zephyr_app/tests/olt_proto_test.c new file mode 100644 index 0000000..ba29fff --- /dev/null +++ b/zephyr_app/tests/olt_proto_test.c @@ -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 +#include + +#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; +}