diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2782ff918..f0c456f4b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -763,7 +763,7 @@ The repo registers the server via `.mcp.json` at the repo root - Claude Code / C **One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port. -### MCP tool surface (43 tools) +### MCP tool surface (44 tools) Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a few high-value signatures are called out here. @@ -771,7 +771,7 @@ Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a - **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` - **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` -- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` +- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `inject_frame` (inject an over-the-air-style frame into the RX pipeline - see below), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` - **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` - **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` env vars first, falls back to VID auto-detection. @@ -781,6 +781,15 @@ Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a **TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step - use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See the meshtastic-mcp repo's README § "TCP / native-host nodes". +### Frame injection: testing the off-air receive path + +The `toRadio` API can only inject **locally-originated** traffic - the firmware forces `p.from = 0` in `MeshService::handleToRadio`, which bypasses the `from != 0` receive path and everything gated on it (remote admin authorization, the admin session-passkey check, hop handling, promiscuous sniffing). To exercise those paths you either need a second transmitting radio, or **frame injection**: a build-flagged seam that delivers a client-supplied frame into the real RX pipeline as if it arrived off the LoRa chip. + +- **Firmware:** build with `-D MESHTASTIC_ENABLE_FRAME_INJECTION=1` (`src/configuration.h`, off by default - it forges over-the-air traffic and must never ship enabled). `MeshService::injectAsReceived` extends the existing portduino `SimRadio` `SIMULATOR_APP` path to real hardware: it unwraps a `Compressed` envelope (`portnum == UNKNOWN_APP` → verbatim ciphertext the router decrypts; else → decoded payload for that portnum), then calls `router->enqueueReceivedMessage()` - the exact entry point `RadioLibInterface::handleReceiveInterrupt` uses. Injection is reached before the `p.from = 0` line, so a forged sender survives; `from == 0` is dropped to match real RX. +- **Host:** drive it with the meshtastic-mcp `inject_frame` tool (or `cli/meshinject.py`). The crafter replicates channel crypto (default-PSK expansion, `xorHash` channel hash, AES-CTR with the `packetId|from|0` nonce), so an encrypted frame decrypts on-device as if received. Modes: `text`, `raw`, `admin` (+ `pki`/`public_key` for the PKC-admin path), `ciphertext`, `fuzz` (malformed-frame decode-path/crash testing). +- **Example - remote-admin session-key repro:** set the target's `admin_key[0]` to a key you hold, then inject an `admin` set_owner with `pki=true`, that key, and a stale `session_hex`. The node logs `PKC admin payload with authorized sender key` → `Expected session key: 00…` → `Admin message without session_key!` - the exact ndoo scenario, on real silicon. Capture logs via `set_debug_log_api` on the same connection. +- **nRF52 gotcha:** the USB CDC wedges under rapid `SerialInterface` open/close churn (unrelated to injection) - keep setup + inject + log-capture on one connection; recover a hung board via a 1200 bps-touch DFU reflash. + ### Hardware test suite (`run-tests.sh`, from a meshtastic-mcp checkout) The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c788b7942..777193a46 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,7 +47,7 @@ jobs: pio upgrade - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 diff --git a/AGENTS.md b/AGENTS.md index 7c25bdc2f..9423c5169 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ The [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) server expose - **Serial sessions**: `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` - **Device writes** (require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **Frame injection**: `inject_frame` - deliver a crafted frame into a board's real RX pipeline as if received off LoRa (reaches `from != 0` / decrypt / remote-admin paths the `toRadio` API can't). Needs firmware built with `-D MESHTASTIC_ENABLE_FRAME_INJECTION=1` (`MeshService::injectAsReceived`); sim nodes always. See the copilot-instructions **Frame injection** section. - **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*` diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index 4b4fe0b82..46a044e8d 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -153,6 +153,30 @@ Display: ### You can also specify the spi device for the display to use # spidev: spidev0.0 +### HUB75 RGB LED matrix panel (Raspberry Pi only, via hzeller/rpi-rgb-led-matrix). +### Requires the librgbmatrix library to be installed (so `pkg-config rgbmatrix` resolves) and +### the firmware built with it present. meshtasticd must run as root (or with /dev/gpiomem +### access) and on-board audio disabled (dtparam=audio=off) so it doesn't fight the PWM timing. +### The library owns the GPIO pins - they are chosen by HardwareMapping, not set individually. +# Panel: HUB75 +# HUB75: +# HardwareMapping: adafruit-hat-pwm # regular | adafruit-hat | adafruit-hat-pwm | regular-pi1 ... +# Rows: 64 # pixel rows per panel (e.g. 32 or 64) +# Cols: 64 # pixel columns per panel +# ChainLength: 1 # panels daisy-chained (width = Cols * ChainLength) +# Parallel: 1 # parallel chains (height = Rows * Parallel) +# Brightness: 80 # percent, 1..100 +# PWMBits: 11 # lower = higher refresh, fewer colors +# GPIOSlowdown: 4 # raise for faster Pis / long cables if the panel ghosts or flickers +# RGBSequence: RGB # reorder if your panel's colors are swapped (e.g. RBG, BGR) +# ScanMode: 0 # 0=progressive, 1=interlaced +# RowAddressType: 0 # 0=default, 1=AB-addressed (some 64x64 panels) +# Multiplexing: 0 # 0=direct, 1=stripe, 2=checker, ... +# PanelType: "" # e.g. FM6126A for panels needing a special init +# PixelMapper: "" # e.g. "U-mapper;Rotate:90" +# LimitRefreshRateHz: 0 # 0 = no limit +# DisableHardwarePulsing: false + Touchscreen: ### Note, at least for now, the touchscreen must have a CS pin defined, even if you let Linux manage the CS switching. @@ -167,7 +191,6 @@ Touchscreen: ### You can also specify the spi device for the touchscreen to use # spidev: spidev0.0 - Input: ### Configure device for direct keyboard input diff --git a/bin/config.d/display-hub75-64x64.yaml b/bin/config.d/display-hub75-64x64.yaml new file mode 100644 index 000000000..704a4c6b9 --- /dev/null +++ b/bin/config.d/display-hub75-64x64.yaml @@ -0,0 +1,37 @@ +Meta: + name: HUB75 RGB LED Matrix (64x64) + support: community + compatible: + - raspberry-pi + +### HUB75 RGB LED matrix panel driven by hzeller/rpi-rgb-led-matrix. +### +### Prerequisites: +### - Install the rpi-rgb-led-matrix library so `pkg-config rgbmatrix` resolves, and build the +### firmware with it present (the HUB75 backend is compiled in automatically when found). +### - meshtasticd must run as root (or have /dev/gpiomem access) for the library's direct GPIO. +### - Disable on-board audio (dtparam=audio=off in /boot/firmware/config.txt) - it shares the +### PWM hardware and causes flicker otherwise. +### +### The library owns the GPIO pins; they are selected by HardwareMapping (matching your HAT / +### wiring), not assigned individually. +Display: + Panel: HUB75 + HUB75: + HardwareMapping: adafruit-hat-pwm + Rows: 64 + Cols: 64 + ChainLength: 1 + Parallel: 1 + Brightness: 80 + PWMBits: 11 + GPIOSlowdown: 4 + RGBSequence: RGB + # Uncomment/tune as needed for your specific panel: + # ScanMode: 0 + # RowAddressType: 0 + # Multiplexing: 0 + # PanelType: FM6126A + # PixelMapper: "" + # LimitRefreshRateHz: 0 + # DisableHardwarePulsing: false diff --git a/bin/config.d/lora-pimesh-1w-v1.yaml b/bin/config.d/lora-pimesh-1w-v1.yaml new file mode 100644 index 000000000..ceb33b5cd --- /dev/null +++ b/bin/config.d/lora-pimesh-1w-v1.yaml @@ -0,0 +1,21 @@ +# PiMesh-1W (V1) E22-900M30S +# https://meshsmith.net/products/pimesh-1w +Meta: + name: PiMesh-1W (V1) E22-900M30S + support: community + compatible: + - raspberry-pi + +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Reset: 18 + Busy: 20 + TXen: 13 + RXen: 12 + DIO3_TCXO_VOLTAGE: true + SX126X_MAX_POWER: 22 + spidev: spidev0.0 + +# preamble_length: 17 diff --git a/bin/config.d/lora-pimesh-1w-v2.yaml b/bin/config.d/lora-pimesh-1w-v2.yaml new file mode 100644 index 000000000..b6b812f32 --- /dev/null +++ b/bin/config.d/lora-pimesh-1w-v2.yaml @@ -0,0 +1,21 @@ +# PiMesh-1W (V2) E22P-915M30S / E22P-868M30S +# https://meshsmith.net/products/pimesh-1w +Meta: + name: PiMesh-1W (V2) E22P-915M30S / E22P-868M30S + support: community + compatible: + - raspberry-pi + +Lora: + Module: sx1262 + IRQ: 6 + Reset: 18 + Busy: 5 + CS: 8 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: true + SX126X_MAX_POWER: 22 + spidev: spidev0.0 + +# add the following to /boot/firmware/config.txt +# gpio=26=op,dh diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py index cb32f18a4..08d010e08 100644 --- a/bin/platformio-custom.py +++ b/bin/platformio-custom.py @@ -340,6 +340,18 @@ for lb in env.GetLibBuilders(): lb.env.Append(CPPDEFINES=[("APP_VERSION", verObj["long"])]) break +# DEBUG_MUTE already compiles every LOG_* call out entirely; nanopb's own error-message +# strings are a separate mechanism it doesn't touch, so mirror the same intent into nanopb. +debug_mute_defined = any( + d == "DEBUG_MUTE" or (isinstance(d, tuple) and d[0] == "DEBUG_MUTE") for d in env.get("CPPDEFINES", []) +) +if debug_mute_defined: + projenv.Append(CPPDEFINES=[("PB_NO_ERRMSG", 1)]) + for lb in env.GetLibBuilders(): + if lb.name == "Nanopb": + lb.env.Append(CPPDEFINES=[("PB_NO_ERRMSG", 1)]) + break + # Get the display resolution from macros def get_display_resolution(build_flags): # Check "DISPLAY_SIZE" to determine the screen resolution diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md index 0a818ff41..d2debef3f 100644 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -257,14 +257,14 @@ so they are stable as listed here: `region_groups` (region → group_index): -| group | regions | -| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 | -| 1 | EU_868 | -| 2 | EU_866 | -| 3 | EU_N_868 | -| 4 | ITU1_2M, ITU2_2M, ITU3_2M | -| 5 | ITU2_125CM | +| group | regions | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 | +| 1 | EU_868 | +| 2 | EU_866 | +| 3 | EU_N_868 | +| 4 | ITU1_2M, ITU2_2M, ITU3_2M | +| 5 | ITU2_125CM | > Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups > because they differ in `licensed_only`. Decoders must key on the group, not on the preset diff --git a/platformio.ini b/platformio.ini index 250de5004..225a8f938 100644 --- a/platformio.ini +++ b/platformio.ini @@ -69,7 +69,7 @@ monitor_speed = 115200 monitor_filters = direct lib_deps = # renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master - https://github.com/meshtastic/esp8266-oled-ssd1306/archive/2e26010040e028baee72e2093402fa7b3c59e430.zip + https://github.com/meshtastic/esp8266-oled-ssd1306/archive/9d9ba7e43ed1a5e1865fc9686513eab47fb079ff.zip # renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip # renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master @@ -127,7 +127,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/effbb925a7cbd3ab794dfcc5fa16228217d18408.zip + https://github.com/meshtastic/device-ui/archive/33917d583eb3f4d6f93a24122a88101221afcc2d.zip ; Common libs for environmental measurements in telemetry module [environmental_base] @@ -191,7 +191,7 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit LTR390 Library packageName=adafruit/Adafruit_LTR390 https://github.com/adafruit/Adafruit_LTR390/archive/refs/tags/1.1.2.zip # renovate: datasource=github-tags depName=Adafruit PCT2075 packageName=adafruit/Adafruit_PCT2075 - https://github.com/adafruit/Adafruit_PCT2075/archive/refs/tags/1.0.6.zip + https://github.com/adafruit/Adafruit_PCT2075/archive/1.3.1.zip # renovate: datasource=github-tags depName=DFRobot_BMM150 packageName=dfrobot/DFRobot_BMM150 https://github.com/DFRobot/DFRobot_BMM150/archive/refs/tags/V1.0.0.zip # renovate: datasource=github-tags depName=SparkFun MMC5983MA Magnetometer packageName=sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library diff --git a/protobufs b/protobufs index 9d589c132..f5b94bc36 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 9d589c1321478193885d6cecf852bf02d14fc92b +Subproject commit f5b94bc36786e3862eb16f4a68169709ee23c809 diff --git a/src/FSCommon.h b/src/FSCommon.h index c974840fe..080ede691 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -57,7 +57,6 @@ using namespace Adafruit_LittleFS_Namespace; #endif void fsInit(); -void fsListFiles(); bool copyFile(const char *from, const char *to); bool renameFile(const char *pathFrom, const char *pathTo); bool fsFormat(); diff --git a/src/Power.cpp b/src/Power.cpp index 1c1f34cd4..4118dac53 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -906,7 +906,7 @@ void Power::shutdown() #if HAS_SCREEN messageStore.saveToFlash(); #endif -#if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) +#if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_STM32WL) #ifdef PIN_LED1 ledOff(PIN_LED1); #endif diff --git a/src/PowerStatus.h b/src/PowerStatus.h index fe4543e08..0adca488a 100644 --- a/src/PowerStatus.h +++ b/src/PowerStatus.h @@ -51,9 +51,6 @@ class PowerStatus : public Status bool getHasUSB() const { return hasUSB == OptTrue; } - /// Can we even know if this board has USB power or not - bool knowsUSB() const { return hasUSB != OptUnknown; } - bool getIsCharging() const { return isCharging == OptTrue; } int getBatteryVoltageMv() const { return batteryVoltageMv; } diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 3ca197ca4..e53806ac7 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -1,8 +1,8 @@ #include "RedirectablePrint.h" #include "NodeDB.h" -#include "RTC.h" #include "concurrency/OSThread.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "memGet.h" #include "mesh/generated/meshtastic/mesh.pb.h" diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index d6810e123..b32242212 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -30,6 +30,16 @@ SerialConsole *console; +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL +// Last-seen USB-CDC host link (DTR/mount) state, sampled each runOnce() so a +// physical unplug/replug re-locks the per-connection admin auth (see runOnce()). +// Kept at file scope rather than as a member both because there is exactly one +// console singleton and because adding per-instance members to the PhoneAPI +// hierarchy has historically perturbed nRF52 USB-CDC enumeration (see PhoneAPI.h). +// Only compiled on lockdown (nRF52) builds. +static bool s_serialLinkUp = false; +#endif + /// Create the shared serial console once and register receive wakeups. void consoleInit() { @@ -88,6 +98,30 @@ SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), con /// Service one serial API iteration and select the next polling interval. int32_t SerialConsole::runOnce() { +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL + // Lockdown (nRF52) builds only. The SerialConsole is a process-lifetime + // singleton, so its inherited PhoneAPI object - and therefore its entry in + // the per-connection admin-auth slot table (keyed by PhoneAPI*) - is reused + // for every USB/serial client for the whole boot. Nothing re-locks that slot + // when the operator unplugs and a different client plugs in before the + // 15-minute inactivity timeout fires, so a fresh client would inherit the + // prior operator's admin authorization. Re-lock when the physical USB-CDC link + // drops - the serial analog of the BLE onDisconnect() -> close() session reset. + // + // On the nRF52 TinyUSB (Adafruit) core, (bool)Port == tud_cdc_n_connected(): + // it goes false on cable unplug or host port-close (DTR de-assert). close() + // frees the auth slot and resets PhoneAPI state, so whoever connects next + // re-locks via handleStartConfig()'s !isConnected() branch on their first + // want_config - the same physical-link boundary BLE enforces in onConnect(). + // Console transports without a real DTR line (e.g. a UART USER_DEBUG_PORT) hold + // this constant, so no edge fires and we fall back to the existing inactivity + // timeout - no worse than the pre-fix behavior. + const bool linkUp = static_cast(Port); + if (s_serialLinkUp && !linkUp) + close(); + s_serialLinkUp = linkUp; +#endif + #ifdef HELTEC_MESH_SOLAR // After enabling the mesh solar serial port module configuration, command processing is handled by the serial port module. if (moduleConfig.serial.enabled && moduleConfig.serial.override_console_serial_port && diff --git a/src/configuration.h b/src/configuration.h index aaba2bbfd..672d8b6f1 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -88,6 +88,14 @@ along with this program. If not, see . #define MESHTASTIC_PREHOP_DROP 1 #endif +// Debug/test only: let a wired client (serial/TCP) inject frames into the RX pipeline as if they had +// arrived over LoRa - a SIMULATOR_APP ToRadio packet is delivered through the real receive path on real +// hardware (see MeshService::injectAsReceived). This forges over-the-air traffic, so it MUST stay 0 in +// any shipping build; enable per-build with -D MESHTASTIC_ENABLE_FRAME_INJECTION=1. +#ifndef MESHTASTIC_ENABLE_FRAME_INJECTION +#define MESHTASTIC_ENABLE_FRAME_INJECTION 0 +#endif + /// Convert a preprocessor name into a quoted string #define xstr(s) ystr(s) #define ystr(s) #s @@ -416,6 +424,11 @@ along with this program. If not, see . #ifndef HAS_TFT #define HAS_TFT 0 #endif +// Opt-in: build the BaseUI games frame (Snake). Off by default; enable per build/variant with +// -DBASEUI_HAS_GAMES=1 (requires HAS_SCREEN and a non-color BaseUI display). +#ifndef BASEUI_HAS_GAMES +#define BASEUI_HAS_GAMES 0 +#endif #ifndef HAS_WIRE #define HAS_WIRE 0 #endif diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 430030422..0a942f10d 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -8,10 +8,10 @@ #include "GpioLogic.h" #include "NodeDB.h" #include "PowerMon.h" -#include "RTC.h" #include "Throttle.h" #include "buzz.h" #include "concurrency/Periodic.h" +#include "gps/RTC.h" #include "meshUtils.h" #include "main.h" // pmu_found diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index f225ab61c..8324bc3c0 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -36,19 +36,52 @@ GeoCoord::GeoCoord(double lat, double lon, int32_t alt) : _altitude(alt) GeoCoord::setCoords(); } -// Initialize all the coordinate systems +// Initialize DMS eagerly (cheap, most commonly used); UTM/MGRS/OSGR/OLC are computed lazily on +// first access via their getters (see ensure*() below), since most callers never touch them. void GeoCoord::setCoords() { double lat = _latitude * 1e-7; double lon = _longitude * 1e-7; GeoCoord::latLongToDMS(lat, lon, _dms); - GeoCoord::latLongToUTM(lat, lon, _utm); - GeoCoord::latLongToMGRS(lat, lon, _mgrs); - GeoCoord::latLongToOSGR(lat, lon, _osgr); - GeoCoord::latLongToOLC(lat, lon, _olc); + _utmValid = false; + _mgrsValid = false; + _osgrValid = false; + _olcValid = false; _dirty = false; } +void GeoCoord::ensureUTM() const +{ + if (!_utmValid) { + GeoCoord::latLongToUTM(_latitude * 1e-7, _longitude * 1e-7, _utm); + _utmValid = true; + } +} + +void GeoCoord::ensureMGRS() const +{ + if (!_mgrsValid) { + GeoCoord::latLongToMGRS(_latitude * 1e-7, _longitude * 1e-7, _mgrs); + _mgrsValid = true; + } +} + +void GeoCoord::ensureOSGR() const +{ + if (!_osgrValid) { + GeoCoord::latLongToOSGR(_latitude * 1e-7, _longitude * 1e-7, _osgr); + _osgrValid = true; + } +} + +void GeoCoord::ensureOLC() const +{ + if (!_olcValid) { + GeoCoord::latLongToOLC(_latitude * 1e-7, _longitude * 1e-7, _olc); + _olcValid = true; + } +} + void GeoCoord::updateCoords(int32_t lat, int32_t lon, int32_t alt) { // If marked dirty or new coordinates diff --git a/src/gps/GeoCoord.h b/src/gps/GeoCoord.h index 658c177b3..3bb6606f2 100644 --- a/src/gps/GeoCoord.h +++ b/src/gps/GeoCoord.h @@ -65,14 +65,24 @@ class GeoCoord int32_t _altitude = 0; DMS _dms = {}; - UTM _utm = {}; - MGRS _mgrs = {}; - OSGR _osgr = {}; - OLC _olc = {}; + // Computed lazily on first access via ensure*() below; mutable so const getters can populate + // them on demand. + mutable UTM _utm = {}; + mutable MGRS _mgrs = {}; + mutable OSGR _osgr = {}; + mutable OLC _olc = {}; + mutable bool _utmValid = false; + mutable bool _mgrsValid = false; + mutable bool _osgrValid = false; + mutable bool _olcValid = false; bool _dirty = true; void setCoords(); + void ensureUTM() const; + void ensureMGRS() const; + void ensureOSGR() const; + void ensureOLC() const; public: GeoCoord(); @@ -123,26 +133,86 @@ class GeoCoord uint32_t getDMSLonSec() const { return _dms.lonSec; } char getDMSLonCP() const { return _dms.lonCP; } - // UTM getters - uint8_t getUTMZone() const { return _utm.zone; } - char getUTMBand() const { return _utm.band; } - uint32_t getUTMEasting() const { return _utm.easting; } - uint32_t getUTMNorthing() const { return _utm.northing; } + // UTM getters (computed on first access - see ensureUTM()) + uint8_t getUTMZone() const + { + ensureUTM(); + return _utm.zone; + } + char getUTMBand() const + { + ensureUTM(); + return _utm.band; + } + uint32_t getUTMEasting() const + { + ensureUTM(); + return _utm.easting; + } + uint32_t getUTMNorthing() const + { + ensureUTM(); + return _utm.northing; + } - // MGRS getters - uint8_t getMGRSZone() const { return _mgrs.zone; } - char getMGRSBand() const { return _mgrs.band; } - char getMGRSEast100k() const { return _mgrs.east100k; } - char getMGRSNorth100k() const { return _mgrs.north100k; } - uint32_t getMGRSEasting() const { return _mgrs.easting; } - uint32_t getMGRSNorthing() const { return _mgrs.northing; } + // MGRS getters (computed on first access - see ensureMGRS()) + uint8_t getMGRSZone() const + { + ensureMGRS(); + return _mgrs.zone; + } + char getMGRSBand() const + { + ensureMGRS(); + return _mgrs.band; + } + char getMGRSEast100k() const + { + ensureMGRS(); + return _mgrs.east100k; + } + char getMGRSNorth100k() const + { + ensureMGRS(); + return _mgrs.north100k; + } + uint32_t getMGRSEasting() const + { + ensureMGRS(); + return _mgrs.easting; + } + uint32_t getMGRSNorthing() const + { + ensureMGRS(); + return _mgrs.northing; + } - // OSGR getters - char getOSGRE100k() const { return _osgr.e100k; } - char getOSGRN100k() const { return _osgr.n100k; } - uint32_t getOSGREasting() const { return _osgr.easting; } - uint32_t getOSGRNorthing() const { return _osgr.northing; } + // OSGR getters (computed on first access - see ensureOSGR()) + char getOSGRE100k() const + { + ensureOSGR(); + return _osgr.e100k; + } + char getOSGRN100k() const + { + ensureOSGR(); + return _osgr.n100k; + } + uint32_t getOSGREasting() const + { + ensureOSGR(); + return _osgr.easting; + } + uint32_t getOSGRNorthing() const + { + ensureOSGR(); + return _osgr.northing; + } - // OLC getter - void getOLCCode(char *code) { strncpy(code, _olc.code, OLC_CODE_LEN + 1); } // +1 for null termination + // OLC getter (computed on first access - see ensureOLC()) + void getOLCCode(char *code) + { + ensureOLC(); + strncpy(code, _olc.code, OLC_CODE_LEN + 1); // +1 for null termination + } }; \ No newline at end of file diff --git a/src/gps/NMEAWPL.cpp b/src/gps/NMEAWPL.cpp index f4249ca62..5259427e6 100644 --- a/src/gps/NMEAWPL.cpp +++ b/src/gps/NMEAWPL.cpp @@ -1,7 +1,7 @@ #if !MESHTASTIC_EXCLUDE_GPS #include "NMEAWPL.h" #include "GeoCoord.h" -#include "RTC.h" +#include "gps/RTC.h" #include /* ------------------------------------------- diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index ad0bdec04..400bfd1aa 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -1,4 +1,4 @@ -#include "RTC.h" +#include "gps/RTC.h" #include "configuration.h" #include "detect/ScanI2C.h" #include "main.h" @@ -7,6 +7,10 @@ #include #include +#if HAS_LSE +#include +#endif + static RTCQuality currentQuality = RTCQualityNone; uint32_t lastSetFromPhoneNtpOrGps = 0; @@ -211,6 +215,30 @@ RTCSetResult readFromRTC() return RTCSetResultSuccess; } } +#elif HAS_LSE + if (stm32wlRtcAvailable()) { + uint32_t now = millis(); + tv.tv_sec = STM32RTC::getInstance().getEpoch(); + tv.tv_usec = 0; + uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms +#ifdef BUILD_EPOCH + if (tv.tv_sec < BUILD_EPOCH) { + if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { + LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + lastTimeValidationWarning = millis(); + } + return RTCSetResultInvalidTime; + } +#endif + if (currentQuality == RTCQualityNone) { + RTCQuality oldQuality = currentQuality; + timeStartMsec = now; + zeroOffsetSecs = tv.tv_sec; + currentQuality = RTCQualityDevice; + triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality); + } + return RTCSetResultSuccess; + } #else return readFromSystemTimeFallback(); #endif @@ -335,6 +363,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd LOG_WARN("Failed to set time for RX8130CE"); } } +#elif HAS_LSE + if (stm32wlRtcAvailable()) { + STM32RTC::getInstance().setEpoch(tv->tv_sec); + } #elif defined(ARCH_ESP32) || defined(ARCH_RP2040) settimeofday(tv, NULL); #endif diff --git a/src/gps/RTC.h b/src/gps/RTC.h index b69a99ec9..2eb5293ea 100644 --- a/src/gps/RTC.h +++ b/src/gps/RTC.h @@ -8,6 +8,11 @@ #include #endif +#if HAS_LSE +// True once the STM32WL LSE crystal has locked and the hardware RTC is running (see stm32wlSetup()). +bool stm32wlRtcAvailable(); +#endif + enum RTCQuality { /// We haven't had our RTC set yet diff --git a/src/graphics/HUB75Display.cpp b/src/graphics/HUB75Display.cpp new file mode 100644 index 000000000..36ca64f39 --- /dev/null +++ b/src/graphics/HUB75Display.cpp @@ -0,0 +1,156 @@ +#include "configuration.h" + +#if defined(USE_HUB75) + +#include "HUB75Display.h" +#include "TFTColorRegions.h" +#include "TFTPalette.h" +#include +#include + +HUB75Display::HUB75Display(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C) +{ + // The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306 + // page layout). Geometry is fixed by the wired panel size. +#if defined(SCREEN_ROTATE) + setGeometry(GEOMETRY_RAWMODE, TFT_HEIGHT, TFT_WIDTH); +#else + setGeometry(GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); +#endif + LOG_DEBUG("HUB75Display %dx%d", (int)TFT_WIDTH, (int)TFT_HEIGHT); +} + +HUB75Display::~HUB75Display() +{ + if (matrix) { + delete matrix; + matrix = nullptr; + } +} + +// Bring up the matrix DMA driver from the wired HUB75 pins. +bool HUB75Display::connect() +{ + LOG_INFO("Do HUB75 init"); + + HUB75_I2S_CFG::i2s_pins pins = {HUB75_R1, HUB75_G1, HUB75_B1, HUB75_R2, HUB75_G2, HUB75_B2, HUB75_A, + HUB75_B, HUB75_C, HUB75_D, HUB75_E, HUB75_LAT, HUB75_OE, HUB75_CLK}; + + HUB75_I2S_CFG cfg(TFT_WIDTH, TFT_HEIGHT, 1 /* chain length */, pins); + + cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity) + cfg.latch_blanking = 4; // clean row transitions + cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half + cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA + // footprint. display() draws directly into the + // live buffer with a dirty-diff + + matrix = new MatrixPanel_I2S_DMA(cfg); + bool ok = matrix->begin(); + if (!ok) { + LOG_ERROR("HUB75 matrix->begin() failed (DMA buffer alloc?)"); + delete matrix; + matrix = nullptr; + return false; + } + matrix->setBrightness8(brightness); + matrix->clearScreen(); + return true; +} + +void HUB75Display::display() +{ + if (!matrix) + return; + + const uint16_t onNative = graphics::TFTPalette::White; + const uint16_t offNative = graphics::getThemeBodyBg(); + + const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8)); + const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8)); + + bool forceFull = firstFrame || !haveThemeDefaults || onBe != lastOnBe || offBe != lastOffBe; + +#if GRAPHICS_TFT_COLORING_ENABLED + const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0; + const uint32_t colorSig = graphics::getTFTColorFrameSignature(); + if (colorSig != lastColorSig) + forceFull = true; +#endif + + for (uint16_t y = 0; y < displayHeight; y++) { + const uint32_t yByteIndex = (y / 8) * displayWidth; + const uint8_t yByteMask = (uint8_t)(1 << (y & 7)); + +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + graphics::beginTFTColorRow((int16_t)y); +#endif + + for (uint16_t x = 0; x < displayWidth; x++) { + const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0; + + // Skip pixels whose mono bit is unchanged (unless a full repaint is + // forced). The panel is single-buffered, so untouched pixels persist. + if (!forceFull && (((buffer_back[x + yByteIndex] & yByteMask) != 0) == isset)) + continue; + + uint16_t be; +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe); + else + be = isset ? onBe : offBe; +#else + be = isset ? onBe : offBe; +#endif + + const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565 + const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3); + const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2); + const uint8_t b = (uint8_t)((c & 0x1F) << 3); + matrix->drawPixelRGB888(x, y, r, g, b); + } + } + + // Remember what the panel now shows so the next frame can diff against it. + memcpy(buffer_back, buffer, displayBufferSize); + + haveThemeDefaults = true; + lastOnBe = onBe; + lastOffBe = offBe; + firstFrame = false; +#if GRAPHICS_TFT_COLORING_ENABLED + lastColorSig = colorSig; + // Regions are re-registered every frame by the renderers; clear so they + // don't accumulate across frames. + graphics::clearTFTColorRegions(); +#endif +} + +void HUB75Display::sendCommand(uint8_t com) +{ + if (!matrix) + return; + + switch (com) { + case DISPLAYON: + matrix->setBrightness8(brightness); + break; + case DISPLAYOFF: + matrix->setBrightness8(0); + break; + default: + // Drop all other SSD1306 init/config commands - not meaningful for the matrix. + break; + } +} + +void HUB75Display::setDisplayBrightness(uint8_t _brightness) +{ + brightness = _brightness; + if (matrix) + matrix->setBrightness8(brightness); +} + +#endif // USE_HUB75 diff --git a/src/graphics/HUB75Display.h b/src/graphics/HUB75Display.h new file mode 100644 index 000000000..aa17a5a2a --- /dev/null +++ b/src/graphics/HUB75Display.h @@ -0,0 +1,48 @@ +#pragma once + +#include "configuration.h" + +#if defined(USE_HUB75) + +#include + +#ifndef HUB75_BRIGHTNESS_DEFAULT +#define HUB75_BRIGHTNESS_DEFAULT 180 +#endif + +class MatrixPanel_I2S_DMA; + +/** + * A color display backend that drives a HUB75 RGB LED matrix panel from the + * BaseUI color path. + */ +class HUB75Display : public OLEDDisplay +{ + public: + HUB75Display(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus); + ~HUB75Display(); + + // Write the buffer to the matrix (full-frame, region-aware color expansion). + void display() override; + + void setDisplayBrightness(uint8_t brightness); + + protected: + int getBufferOffset(void) override { return 0; } + + void sendCommand(uint8_t com) override; + + bool connect() override; + + private: + MatrixPanel_I2S_DMA *matrix = nullptr; + uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT; + + bool firstFrame = true; + bool haveThemeDefaults = false; + uint16_t lastOnBe = 0; + uint16_t lastOffBe = 0; + uint32_t lastColorSig = 0; +}; + +#endif // USE_HUB75 diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8aebfec6e..73bf04b6d 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -29,6 +29,12 @@ along with this program. If not, see . #if HAS_SCREEN #include "EInkParallelDisplay.h" #include +#if defined(USE_HUB75) +#include "graphics/HUB75Display.h" // ESP32 HUB75 (I2S-DMA) +#endif +#if defined(HAS_HUB75_NATIVE) +#include "HUB75Native.h" // native/Portduino HUB75 (rpi-rgb-led-matrix), from variants/native/portduino +#endif #include "DisplayFormatters.h" #include "TimeFormatters.h" @@ -67,6 +73,9 @@ along with this program. If not, see . #include "mesh/Default.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "modules/ExternalNotificationModule.h" +#if BASEUI_HAS_GAMES +#include "modules/games/GamesModule.h" +#endif #include "modules/WaypointModule.h" #include "sleep.h" #include "target_specific.h" @@ -99,7 +108,11 @@ namespace graphics #define COMPASS_ACTIVE_FRAMERATE 20 // DEBUG +#if BASEUI_HAS_GAMES +#define NUM_EXTRA_FRAMES 4 // text message, debug frame, and the always-present games frame +#else #define NUM_EXTRA_FRAMES 3 // text message and debug frame +#endif // if defined a pixel will blink to show redraws // #define SHOW_REDRAWS #define ASCII_BELL '\x07' @@ -343,7 +356,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct } // Called to trigger a banner with custom message and duration -void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, +void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, std::function bannerCallback) { #ifdef USE_EINK @@ -356,7 +369,10 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; - NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; + if (useBase16) + NotificationRenderer::current_notification_type = notificationTypeEnum::hex_picker; + else + NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; NotificationRenderer::numDigits = digits; NotificationRenderer::currentNumber = 0; @@ -366,6 +382,43 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t updateUiFrame(ui); } +// Called to trigger an arcade-style initials picker (see showNumberPicker for the sibling flow). +void Screen::showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length, + std::function bannerCallback) +{ +#ifdef USE_EINK + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus +#endif + if (length >= sizeof(NotificationRenderer::alphanumericValue)) + length = sizeof(NotificationRenderer::alphanumericValue) - 1; + + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::textInputCallback = bannerCallback; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::curSelected = 0; + NotificationRenderer::current_notification_type = notificationTypeEnum::alphanumeric_picker; + NotificationRenderer::numDigits = length; + + // Seed each position from initialText (uppercased & filtered to A-Z/0-9), defaulting to 'A'. + const size_t seedLen = initialText ? strnlen(initialText, length) : 0; + for (uint8_t i = 0; i < length; i++) { + char c = (i < seedLen) ? initialText[i] : 'A'; + if (c >= 'a' && c <= 'z') + c = static_cast(c - 'a' + 'A'); + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) + c = 'A'; + NotificationRenderer::alphanumericValue[i] = c; + } + NotificationRenderer::alphanumericValue[length] = '\0'; + + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, 2); + ui->setTargetFPS(60); + updateUiFrame(ui); +} + void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback) { @@ -409,6 +462,17 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int pi.drawFrame(display, state, x, y); } +#if BASEUI_HAS_GAMES +// The games frame is a dedicated, always-present frame (unlike generic module frames it is placed +// at a fixed position right after home), so it draws through its own trampoline rather than the +// moduleFrames lockstep used by drawModuleFrame. +static void drawGamesFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + if (gamesModule) + gamesModule->drawFrame(display, state, x, y); +} +#endif + /** * Given a recent lat/lon return a guess of the heading the user is walking on. * @@ -496,6 +560,8 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #else dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); #endif +#elif defined(USE_HUB75) + dispdev = new HUB75Display(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE); #elif defined(USE_SSD1306) dispdev = new SSD1306Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); @@ -514,7 +580,17 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O LOG_INFO("SSD1306 init success"); } #elif ARCH_PORTDUINO - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + + // HUB75 RGB matrix (hzeller/rpi-rgb-led-matrix) is a BaseUI framebuffer panel, selected at + // runtime via config.yaml Display: Panel: HUB75. + if (portduino_config.displayPanel == hub75) { +#if defined(HAS_HUB75_NATIVE) + LOG_DEBUG("Make HUB75Native!"); + dispdev = new HUB75Native(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE); +#else + LOG_ERROR("HUB75 panel requested but rpi-rgb-led-matrix not compiled in!"); +#endif + } else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { if (portduino_config.displayPanel != no_screen) { LOG_DEBUG("Make TFTDisplay!"); dispdev = new TFTDisplay(address.address, -1, -1, geometry, @@ -1301,6 +1377,15 @@ void Screen::setFrames(FrameFocus focus) indicatorIcons.push_back(icon_home); } +#if BASEUI_HAS_GAMES + // Games frame: always present (even with no game running), positioned directly after home. + if (gamesModule) { + fsi.positions.games = numframes; + normalFrames[numframes++] = drawGamesFrame; + indicatorIcons.push_back(joystick_small); + } +#endif + fsi.positions.textMessage = numframes; normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame; indicatorIcons.push_back(icon_mail); @@ -2073,6 +2158,12 @@ int Screen::handleInputEvent(const InputEvent *event) if (module && module->interceptingKeyboardInput()) inputIntercepted = true; } +#if BASEUI_HAS_GAMES + // The games frame isn't a moduleFrame, so check it explicitly: while a game is running it + // owns the D-pad (turns/pause) and we must not switch frames or open menus underneath it. + if (gamesModule && gamesModule->interceptingKeyboardInput()) + inputIntercepted = true; +#endif // If no modules are using the input, move between frames if (!inputIntercepted) { @@ -2147,6 +2238,11 @@ int Screen::handleInputEvent(const InputEvent *event) } else if (event->inputEvent == INPUT_BROKER_SELECT) { if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { menuHandler::homeBaseMenu(); +#if BASEUI_HAS_GAMES + } else if (gamesModule && framesetInfo.positions.games != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.games) { + gamesModule->launchGame(); // launch the game shown on the attract screen +#endif } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) { menuHandler::systemBaseMenu(); #if HAS_GPS @@ -2214,6 +2310,11 @@ bool Screen::isOverlayBannerShowing() return NotificationRenderer::isOverlayBannerShowing(); } +bool Screen::isGamesFrameShown() +{ + return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games; +} + } // namespace graphics #else diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 424b162b4..4aeec6ce8 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -26,6 +26,9 @@ enum notificationTypeEnum { // LOCKED frame. Without this, a first-pair on a locked device cannot // complete because the PIN never renders. pairing_pin, + // Arcade-style initials entry: like number_picker/hex_picker, but each position cycles + // through A-Z and 0-9. The assembled string is returned via a text (std::string) callback. + alphanumeric_picker, }; struct BannerOverlayOptions { @@ -284,6 +287,10 @@ class Screen : public concurrency::OSThread bool isOverlayBannerShowing(); + // True if the always-present games frame is the one currently on screen. Lets the games module + // ignore D-pad input when the player has navigated to a different frame. + bool isGamesFrameShown(); + bool isScreenOn() { return screenOn; } // Stores the last 4 of our hardware ID, to make finding the device for pairing easier @@ -344,7 +351,13 @@ class Screen : public concurrency::OSThread void showOverlayBanner(BannerOverlayOptions); void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); - void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); + void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, + std::function bannerCallback); + // Arcade-style initials entry. `length` positions each cycle A-Z/0-9 (UP/DOWN), LEFT/RIGHT + // moves the cursor, SELECT advances; the assembled string is delivered to `bannerCallback`. + // `initialText` pre-seeds the positions (uppercased & filtered), defaulting to 'A'. + void showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length, + std::function bannerCallback); void showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback); @@ -734,6 +747,7 @@ class Screen : public concurrency::OSThread uint8_t system = 255; uint8_t gps = 255; uint8_t home = 255; + uint8_t games = 255; uint8_t textMessage = 255; uint8_t nodelist_nodes = 255; uint8_t nodelist_location = 255; diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 989d00a73..88ba3f96b 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -3,8 +3,8 @@ #include "MeshService.h" #include "NodeDB.h" #include "Power.h" -#include "RTC.h" #include "draw/NodeListRenderer.h" +#include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TFTColorRegions.h" @@ -147,8 +147,8 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti // Transparent clock headers should inherit whatever body off-color is // already active under the header (important for light/inverted themes). const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg()); - setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW, - headerHeight); + // Intentionally skip the HeaderBackground region, as small screens draw the clock in the unused space in this region, + // and the transparent call was erasing segments from the clock setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor); setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor); } else if (useInvertedHeaderStyle) { diff --git a/src/graphics/TFTColorRegions.h b/src/graphics/TFTColorRegions.h index d0517ad4e..9de74b85a 100644 --- a/src/graphics/TFTColorRegions.h +++ b/src/graphics/TFTColorRegions.h @@ -16,8 +16,9 @@ struct TFTColorRegion { // Required by ST7789 driver: it scans until the first disabled entry. bool enabled = false; }; - +#ifndef MAX_TFT_COLOR_REGIONS static constexpr size_t MAX_TFT_COLOR_REGIONS = 48; +#endif extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS]; enum class TFTColorRole : uint8_t { @@ -39,7 +40,7 @@ enum class TFTColorRole : uint8_t { Count }; -#if HAS_TFT || defined(HAS_SPI_TFT) +#if HAS_TFT || defined(HAS_SPI_TFT) || defined(HAS_HUB75_NATIVE) #define GRAPHICS_TFT_COLORING_ENABLED 1 #else #define GRAPHICS_TFT_COLORING_ENABLED 0 diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index c24633169..f67e35fce 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -121,7 +121,6 @@ static void rak14014_tpIntHandle(void) #elif defined(HACKADAY_COMMUNICATOR) #include -Arduino_DataBus *bus = nullptr; Arduino_GFX *tft = nullptr; #elif defined(ST72xx_DE) @@ -1601,17 +1600,21 @@ bool TFTDisplay::connect() { concurrency::LockGuard g(spiLock); LOG_INFO("Do TFT init"); + // connect() re-runs on every display wake on variants whose handleSetOn() re-inits + // the UI (see the gates in Screen::handleSetOn); construct the driver exactly once. + if (!tft) { #if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1) - tft = new TFT_eSPI; + tft = new TFT_eSPI; #elif defined(HACKADAY_COMMUNICATOR) - bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */); - tft = new Arduino_NV3007(bus, 40, 0 /* rotation */, false /* IPS */, 142 /* width */, 428 /* height */, 12 /* col offset 1 */, - 0 /* row offset 1 */, 14 /* col offset 2 */, 0 /* row offset 2 */, nv3007_279_init_operations, - sizeof(nv3007_279_init_operations)); - + Arduino_DataBus *bus = + new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */); + tft = new Arduino_NV3007(bus, 40, 0 /* rotation */, false /* IPS */, 142 /* width */, 428 /* height */, + 12 /* col offset 1 */, 0 /* row offset 1 */, 14 /* col offset 2 */, 0 /* row offset 2 */, + nv3007_279_init_operations, sizeof(nv3007_279_init_operations)); #else - tft = new LGFX; + tft = new LGFX; #endif + } backlightEnable->set(true); LOG_INFO("Power to TFT Backlight"); diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 243a62932..b4dd5ab8a 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -743,17 +743,18 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1 display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); int line = 1; + int scale = 1; int iconX = SCREEN_WIDTH - chirpy_width - (chirpy_width / 3); int iconY = (SCREEN_HEIGHT - chirpy_height) / 2; int textX_offset = 10; if (currentResolution == ScreenResolution::High) { textX_offset = textX_offset * 4; - const int scale = 2; + scale = 2; + iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3); + iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2; const int bytesPerRow = (chirpy_width + 7) / 8; for (int yy = 0; yy < chirpy_height; ++yy) { - iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3); - iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2; const uint8_t *rowPtr = chirpy + yy * bytesPerRow; for (int xx = 0; xx < chirpy_width; ++xx) { const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); @@ -767,6 +768,19 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1 display->drawXbm(iconX, iconY, chirpy_width, chirpy_height, chirpy); } +#if GRAPHICS_TFT_COLORING_ENABLED + // Colour Chirpy on colour displays. The glyph is a filled head silhouette whose eyes are holes + // (off pixels), so two stacked regions render the proper mascot without a second bitmap: + // A) whole glyph -> green body / frame / legs + // B) the eye band -> black face, with the eye holes turning white via the region's off-colour + // Start the face band one column in from the head's left edge (col 6) so that edge stays green, + // matching the green column already left on the right edge (col 31). + graphics::registerTFTColorRegionDirect(iconX, iconY, chirpy_width * scale, chirpy_height * scale, + graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg()); + graphics::registerTFTColorRegionDirect(iconX + 7 * scale, iconY + 12 * scale, 24 * scale, 16 * scale, + graphics::TFTPalette::Black, graphics::TFTPalette::White); +#endif + int textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("Hello") / 2); display->drawString(textX, getTextPositions(display)[line++], "Hello"); textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("World!") / 2); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 23e0c7b5f..349fe9c4a 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -242,7 +242,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"TH", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_TH}, {"LORA_24", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_LORA_24}, {"UA_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_UA_433}, - {"UA_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_UA_868}, {"MY_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_MY_433}, {"MY_919", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_MY_919}, {"SG_923", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_SG_923}, @@ -2323,8 +2322,10 @@ void menuHandler::testMenu() void menuHandler::numberTest() { - screen->showNumberPicker("Pick a number\n ", 30000, 4, - [](int number_picked) -> void { LOG_WARN("Nodenum: %u", number_picked); }); + screen->showNumberPicker("Verify Nodenum:\n ", 30000, 8, true, [](uint32_t number_picked) -> void { + LOG_DEBUG("Nodenum: 0x%08x", number_picked); + keyVerificationModule->sendInitialRequest(number_picked); + }); } void menuHandler::wifiBaseMenu() @@ -2508,8 +2509,7 @@ void menuHandler::keyVerificationFinalPrompt() options.notificationType = graphics::notificationTypeEnum::selection_picker; options.bannerCallback = [=](int selected) { if (selected == 1) { - auto remoteNodePtr = nodeDB->getMeshNode(keyVerificationModule->getCurrentRemoteNode()); - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + keyVerificationModule->commitVerifiedRemoteNode(); } }; screen->showOverlayBanner(options); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 03e53c3bd..98f229b83 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -54,6 +54,7 @@ bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; uint32_t NotificationRenderer::numDigits = 0; uint32_t NotificationRenderer::currentNumber = 0; +char NotificationRenderer::alphanumericValue[16] = {0}; VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr; std::function NotificationRenderer::textInputCallback = nullptr; @@ -277,6 +278,9 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU case notificationTypeEnum::hex_picker: drawHexPicker(display, state); break; + case notificationTypeEnum::alphanumeric_picker: + drawAlphanumericPicker(display, state); + break; } } @@ -462,6 +466,96 @@ void NotificationRenderer::drawHexPicker(OLEDDisplay *display, OLEDDisplayUiStat drawNotificationBox(display, state, linePointers, totalLines, 0); } +// Arcade-style initials entry. Mirrors drawHexPicker's cursor/confirm flow, but each position +// holds a character from ALPHANUMERIC_CHARS (cycled with UP/DOWN) instead of a packed digit, and +// the assembled string is returned through textInputCallback. +void NotificationRenderer::drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + static const char ALPHANUMERIC_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + constexpr int ALPHANUMERIC_COUNT = sizeof(ALPHANUMERIC_CHARS) - 1; // exclude the NUL + + const char *lineStarts[MAX_LINES + 1] = {0}; + uint16_t lineCount = 0; + + // Parse lines (identical to the number/hex pickers) + char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); + lineStarts[lineCount] = alertBannerMessage; + while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { + lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); + if (lineStarts[lineCount + 1][0] == '\n') + lineStarts[lineCount + 1] += 1; + lineCount++; + } + + auto alphaIndex = [&](char c) -> int { + for (int i = 0; i < ALPHANUMERIC_COUNT; i++) + if (ALPHANUMERIC_CHARS[i] == c) + return i; + return 0; + }; + + // Handle input + if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS || + inEvent.inputEvent == INPUT_BROKER_UP_LONG) { + int idx = (alphaIndex(alphanumericValue[curSelected]) + 1) % ALPHANUMERIC_COUNT; + alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx]; + } else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS || + inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) { + int idx = (alphaIndex(alphanumericValue[curSelected]) + ALPHANUMERIC_COUNT - 1) % ALPHANUMERIC_COUNT; + alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx]; + } else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) { + char k = inEvent.kbchar; + if (k >= 'a' && k <= 'z') + k = static_cast(k - 'a' + 'A'); + if ((k >= 'A' && k <= 'Z') || (k >= '0' && k <= '9')) { // direct keyboard entry + alphanumericValue[curSelected] = k; + curSelected++; + } + } else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) { + curSelected++; + } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { + curSelected--; + } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && + alertBannerUntil != 0) { + resetBanner(); + return; + } + + if (curSelected < 0) + curSelected = 0; + if (curSelected == static_cast(numDigits)) { + auto callback = textInputCallback; // capture before clearing to avoid re-entrancy surprises + std::string result(alphanumericValue, numDigits); + textInputCallback = nullptr; + resetBanner(); + if (callback) + callback(result); + return; + } + + inEvent.inputEvent = INPUT_BROKER_NONE; + if (alertBannerMessage[0] == '\0') + return; + + uint16_t totalLines = lineCount + 2; + const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation + + for (uint16_t i = 0; i < lineCount; i++) { + linePointers[i] = lineStarts[i]; + } + std::string chars = " "; + std::string arrowPointer = " "; + for (uint16_t i = 0; i < numDigits; i++) { + chars += std::string(1, alphanumericValue[i]) + " "; + arrowPointer += (curSelected == static_cast(i)) ? "^ " : "_ "; + } + + linePointers[lineCount++] = chars.c_str(); + linePointers[lineCount++] = arrowPointer.c_str(); + + drawNotificationBox(display, state, linePointers, totalLines, 0); +} + void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state) { static uint32_t selectedNodenum = 0; diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 629cf61d8..08f6f74b0 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -26,6 +26,7 @@ class NotificationRenderer static std::function alertBannerCallback; static uint32_t numDigits; static uint32_t currentNumber; + static char alphanumericValue[16]; // working buffer for the alphanumeric_picker static VirtualKeyboard *virtualKeyboard; static std::function textInputCallback; @@ -43,6 +44,7 @@ class NotificationRenderer static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state); + static void drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1], diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 2e2a35f18..53f016645 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -9,6 +9,9 @@ #if !MESHTASTIC_EXCLUDE_STATUS #include "modules/StatusMessageModule.h" #endif +#if BASEUI_HAS_GAMES +#include "modules/games/GamesModule.h" +#endif #include "UIRenderer.h" #include "airtime.h" #include "gps/GeoCoord.h" @@ -21,8 +24,8 @@ #include "main.h" #include "target_specific.h" #include -#include #include +#include // External variables extern graphics::Screen *screen; @@ -1817,6 +1820,13 @@ constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000; // cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state) { +#if BASEUI_HAS_GAMES + // Hide the navigation bar while a game owns the screen (the attract screen doesn't intercept, + // so the nav bar stays visible there). + if (gamesModule && gamesModule->interceptingKeyboardInput()) + return; +#endif + uint8_t frameToHighlight = state->currentFrame; if (state->frameState == IN_TRANSITION && state->transitionFrameTarget < screen->indicatorIcons.size()) { frameToHighlight = state->transitionFrameTarget; diff --git a/src/graphics/images.h b/src/graphics/images.h index 95c0861fd..86d9efb7b 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -319,6 +319,46 @@ const uint8_t chirpy_small[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f}; #define connection_icon_height 5 const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36}; +// Joystick icon (16x16): a round knob on a shaft over an elliptical base. Drawn on the Snake +// attract screen as a "use the D-pad" controls hint. +#define joystick_width 16 +#define joystick_height 16 +static const uint8_t joystick[] PROGMEM = {0xC0, 0x03, 0xE0, 0x07, 0xF0, 0x0F, 0xF0, 0x0F, 0xE0, 0x07, 0xC0, + 0x03, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0xF8, 0x1F, + 0xFC, 0x3F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFC, 0x3F, 0xF8, 0x1F}; + +// 8x8 joystick, for the navigation bar (which draws 8x8 glyphs, scaled up on hi-res displays). +#define joystick_small_width 8 +#define joystick_small_height 8 +static const uint8_t joystick_small[] PROGMEM = {0x18, 0x3C, 0x3C, 0x18, 0x7E, 0x99, 0xC3, 0x7E}; + +#define snake_width 16 +#define snake_height 16 +static const uint8_t snake[] PROGMEM = {0x80, 0x1F, 0x40, 0x20, 0x20, 0x48, 0x20, 0x40, 0xA0, 0x44, 0x20, + 0x39, 0x40, 0xDE, 0x86, 0x44, 0x0A, 0x19, 0x32, 0x22, 0xC4, 0x47, + 0x1C, 0x4D, 0x6A, 0x4A, 0xFA, 0x47, 0x04, 0x20, 0xF8, 0x1F}; + +#define tetris_width 16 +#define tetris_height 16 +static const uint8_t tetris[] PROGMEM = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x01, 0x80, 0xFD, 0xBF, 0xFD, + 0xBF, 0x81, 0x81, 0xBF, 0xFD, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05, + 0x20, 0x04, 0xE0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + +#define breakout_width 16 +#define breakout_height 16 +// Three staggered brick courses, a ball, and a paddle. XBM: 2 bytes/row, LSB = leftmost pixel. +static const uint8_t breakout[] PROGMEM = {0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0xDD, 0xDD, 0xDD, + 0xDD, 0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0x80, 0x01, + 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0xF0, 0x0F}; + +// Tiny Chirpy runner sprite: square head with two pill eyes + mouth, antenna, and long legs. +// XBM, 2 bytes/row, LSB = leftmost pixel. Used by the Chirpy Runner game. +#define chirpy_run_width 12 +#define chirpy_run_height 16 +static const uint8_t chirpy_run[] PROGMEM = {0x40, 0x00, 0x20, 0x00, 0x40, 0x00, 0xfc, 0x03, 0x04, 0x02, 0x94, + 0x02, 0x94, 0x02, 0x94, 0x02, 0x04, 0x02, 0xf4, 0x02, 0xfc, 0x03, + 0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x08, 0x01}; + #ifdef OLED_TINY #include "img/icon_small.xbm" #else diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp index 82c67c9ff..d2fdc41f9 100644 --- a/src/graphics/niche/InkHUD/Applet.cpp +++ b/src/graphics/niche/InkHUD/Applet.cpp @@ -6,7 +6,7 @@ #include "main.h" -#include "RTC.h" +#include "gps/RTC.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp index 3da912a78..69dbf61af 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp @@ -1,6 +1,6 @@ #ifdef MESHTASTIC_INCLUDE_INKHUD -#include "RTC.h" +#include "gps/RTC.h" #include "GeoCoord.h" #include "NodeDB.h" diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 26befddc6..3b7606e12 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -57,7 +57,6 @@ enum MenuAction { SET_REGION_TH, SET_REGION_LORA_24, SET_REGION_UA_433, - SET_REGION_UA_868, SET_REGION_MY_433, SET_REGION_MY_919, SET_REGION_SG_923, diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 6c1a1c79e..ac1fd1e73 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -8,9 +8,9 @@ #include "MeshService.h" #include "MessageStore.h" #include "Power.h" -#include "RTC.h" #include "Router.h" #include "airtime.h" +#include "gps/RTC.h" #include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" #include "graphics/niche/Utils/FlashData.h" #include "main.h" @@ -835,10 +835,6 @@ void InkHUD::MenuApplet::execute(MenuItem item) applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_433); break; - case SET_REGION_UA_868: - applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_868); - break; - case SET_REGION_MY_433: applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_433); break; @@ -1735,7 +1731,6 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.push_back(MenuItem("TH", MenuAction::SET_REGION_TH, MenuPage::EXIT)); items.push_back(MenuItem("LoRa 2.4", MenuAction::SET_REGION_LORA_24, MenuPage::EXIT)); items.push_back(MenuItem("UA 433", MenuAction::SET_REGION_UA_433, MenuPage::EXIT)); - items.push_back(MenuItem("UA 868", MenuAction::SET_REGION_UA_868, MenuPage::EXIT)); items.push_back(MenuItem("MY 433", MenuAction::SET_REGION_MY_433, MenuPage::EXIT)); items.push_back(MenuItem("MY 919", MenuAction::SET_REGION_MY_919, MenuPage::EXIT)); items.push_back(MenuItem("SG 923", MenuAction::SET_REGION_SG_923, MenuPage::EXIT)); diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index 195de8ecd..682c4de5e 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -9,7 +9,7 @@ #include "meshUtils.h" #include "modules/TextMessageModule.h" -#include "RTC.h" +#include "gps/RTC.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp index 94a87d23d..f3ea67a77 100644 --- a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp @@ -1,6 +1,6 @@ #ifdef MESHTASTIC_INCLUDE_INKHUD -#include "RTC.h" +#include "gps/RTC.h" #include "gps/GeoCoord.h" diff --git a/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp index 1ccf7fc14..2f16da5f5 100644 --- a/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp @@ -2,7 +2,7 @@ #include "./RecentsListApplet.h" -#include "RTC.h" +#include "gps/RTC.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp index 0edde0f7f..31aeaa814 100644 --- a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp @@ -2,7 +2,7 @@ #include "./ThreadedMessageApplet.h" -#include "RTC.h" +#include "gps/RTC.h" #include "mesh/NodeDB.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 6a2c8b46d..ddb4a57b7 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -4,8 +4,8 @@ #include "MessageStore.h" #include "PowerFSM.h" -#include "RTC.h" #include "buzz.h" +#include "gps/RTC.h" #include "modules/ExternalNotificationModule.h" #include "modules/TextMessageModule.h" #include "sleep.h" diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index aaff43959..f6848a590 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -79,6 +79,16 @@ static int axisZone(int value) return 0; } +void LinuxJoystick::emitEvent(input_broker_event event) +{ + InputEvent e = {}; + e.inputEvent = event; + e.source = this->_originName; + e.kbchar = 0; + // LOG_DEBUG("joystick: %s event %d", this->_originName, event); + this->notifyObservers(&e); +} + int32_t LinuxJoystick::runOnce() { if (firstTime) { @@ -107,8 +117,6 @@ int32_t LinuxJoystick::runOnce() if (nfds < 0) { perror("joystick: epoll_wait failed"); return disable(); - } else if (nfds == 0) { - return 50; } for (int i = 0; i < nfds; i++) { @@ -117,42 +125,53 @@ int32_t LinuxJoystick::runOnce() if (rd < (signed int)sizeof(struct input_event)) continue; for (int j = 0; j < rd / ((signed int)sizeof(struct input_event)); j++) { - InputEvent e = {}; - e.inputEvent = INPUT_BROKER_NONE; - e.source = this->_originName; - e.kbchar = 0; unsigned int type = evs[j].type; unsigned int code = evs[j].code; int value = evs[j].value; if (type == EV_ABS) { - // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. - // Emit exactly once when an axis crosses from center to an edge. + // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. Emit on the + // transition to an edge and arm auto-repeat; a held direction repeats below. if (code == ABS_X) { int zone = axisZone(value); - if (zone != axisZone(lastX) && zone != 0) - e.inputEvent = (zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT; - lastX = value; + if (zone != heldX) { + heldX = zone; + if (zone != 0) { + emitEvent((zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + nextRepeatX = millis() + JOY_REPEAT_DELAY_MS; + } + } } else if (code == ABS_Y) { int zone = axisZone(value); - if (zone != axisZone(lastY) && zone != 0) - e.inputEvent = (zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN; - lastY = value; + if (zone != heldY) { + heldY = zone; + if (zone != 0) { + emitEvent((zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + nextRepeatY = millis() + JOY_REPEAT_DELAY_MS; + } + } } } else if (type == EV_KEY && value == 1) { // Look up the configured action for this button; unmapped buttons are ignored. + // Buttons fire once per press (no auto-repeat). auto mapped = buttonMap.find(code); if (mapped != buttonMap.end()) - e.inputEvent = mapped->second; - } - - if (e.inputEvent != INPUT_BROKER_NONE) { - LOG_DEBUG("joystick: %s event %d", this->_originName, e.inputEvent); - this->notifyObservers(&e); + emitEvent(mapped->second); } } } + // Auto-repeat held D-pad directions. Signed comparison is wraparound-safe. + uint32_t now = millis(); + if (heldX != 0 && (int32_t)(now - nextRepeatX) >= 0) { + emitEvent((heldX < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + nextRepeatX = now + JOY_REPEAT_INTERVAL_MS; + } + if (heldY != 0 && (int32_t)(now - nextRepeatY) >= 0) { + emitEvent((heldY < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + nextRepeatY = now + JOY_REPEAT_INTERVAL_MS; + } + return 50; // Poll every 50msec } diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h index 6e6d3c398..7e309b961 100644 --- a/src/input/LinuxJoystick.h +++ b/src/input/LinuxJoystick.h @@ -20,6 +20,10 @@ #define JOY_AXIS_LOW 64 // below this -> "min" edge (0) #define JOY_AXIS_HIGH 192 // above this -> "max" edge (255) +// D-pad auto-repeat while a direction is held (typematic). +#define JOY_REPEAT_DELAY_MS 400 // hold this long before repeats start +#define JOY_REPEAT_INTERVAL_MS 150 // then repeat this often + class LinuxJoystick : public Observable, public concurrency::OSThread { public: @@ -27,10 +31,18 @@ class LinuxJoystick : public Observable, public concurrency: void init(); // Registers this source with the InputBroker void deInit(); // Strictly for cleanly "rebooting" the binary on native + // Current held D-pad zone, updated the instant the axis moves (independent of the typematic + // auto-repeat). -1 = left/up edge, 0 = centered, +1 = right/down edge. Lets a game poll for + // smooth continuous control instead of waiting for the slow repeat events. + int heldXZone() const { return heldX; } + int heldYZone() const { return heldY; } + protected: virtual int32_t runOnce() override; private: + void emitEvent(input_broker_event event); + const char *_originName; bool firstTime = true; @@ -42,10 +54,12 @@ class LinuxJoystick : public Observable, public concurrency: int fd = -1; int epollfd = -1; - // Latch the last emitted axis position so a held direction fires exactly - // once (on the transition away from center), not a continuous stream. - int lastX = JOY_AXIS_CENTER; - int lastY = JOY_AXIS_CENTER; + // D-pad auto-repeat state: currently held zone per axis (-1 / 0 / +1) and the + // next millis() timestamp at which to re-emit while the direction is held. + int heldX = 0; + int heldY = 0; + uint32_t nextRepeatX = 0; + uint32_t nextRepeatY = 0; }; extern LinuxJoystick *aLinuxJoystick; #endif diff --git a/src/main.cpp b/src/main.cpp index cc7c239a4..47d51dea3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,13 +22,13 @@ #include "FSCommon.h" #include "Power.h" -#include "RTC.h" #include "SPILock.h" #include "Throttle.h" #include "concurrency/OSThread.h" #include "concurrency/Periodic.h" #include "detect/ScanI2C.h" #include "error.h" +#include "gps/RTC.h" #if !MESHTASTIC_EXCLUDE_I2C #include "detect/ScanI2CConsumer.h" @@ -816,6 +816,10 @@ void setup() rp2040Setup(); #endif +#ifdef ARCH_STM32WL + stm32wlSetup(); +#endif + // We do this as early as possible because this loads preferences from flash // but we need to do this after main cpu init (esp32setup), because we need the random seed set nodeDB = new NodeDB; diff --git a/src/main.h b/src/main.h index 36995f694..98dcccc71 100644 --- a/src/main.h +++ b/src/main.h @@ -113,7 +113,8 @@ extern bool runASAP; extern bool pauseBluetoothLogging; -void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), rp2040Loop(), clearBonds(), enterDfuMode(); +void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), rp2040Loop(), clearBonds(), enterDfuMode(), + stm32wlSetup(); #ifdef ARCH_ESP32 void esp32ReleaseBluetoothMemoryIfUnused(); #endif diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index b086f5e6b..f1d97ebb4 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -516,7 +516,7 @@ bool Channels::hasDefaultChannel() */ bool Channels::decryptForHash(ChannelIndex chIndex, ChannelHash channelHash) { - if (chIndex > getNumChannels() || getHash(chIndex) != channelHash) { + if (chIndex >= getNumChannels() || getHash(chIndex) != channelHash) { // LOG_DEBUG("Skip channel %d (hash %x) due to invalid hash/index, want=%x", chIndex, getHash(chIndex), // channelHash); return false; diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 73ccb2166..cb8989976 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -224,7 +224,11 @@ bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtas uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut) { uint8_t *auth; - long extraNonceTmp = random(); + // The extra nonce must be unpredictable: use the hardware RNG, falling back to the + // seeded CSPRNG only when no hardware source is available. + uint32_t extraNonceTmp; + if (!HardwareRNG::fill((uint8_t *)&extraNonceTmp, sizeof(extraNonceTmp))) + CryptRNG.rand((uint8_t *)&extraNonceTmp, sizeof(extraNonceTmp)); auth = bytesOut + numBytes; memcpy((uint8_t *)(auth + 8), &extraNonceTmp, sizeof(uint32_t)); // do not use dereference on potential non aligned pointers : *extraNonce = extraNonceTmp; @@ -341,6 +345,32 @@ bool CryptoEngine::setDHPublicKey(uint8_t *pubKey) return true; } +void CryptoEngine::setPendingPublicKey(uint32_t node, const uint8_t *key) +{ + concurrency::LockGuard g(&pendingKeyLock); + pendingKeyVerificationNode = node; + memcpy(pendingKeyVerificationPublicKey, key, 32); + hasPendingKeyVerificationKey = true; +} + +void CryptoEngine::clearPendingPublicKey() +{ + concurrency::LockGuard g(&pendingKeyLock); + pendingKeyVerificationNode = 0; + memset(pendingKeyVerificationPublicKey, 0, 32); + hasPendingKeyVerificationKey = false; +} + +bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out) +{ + concurrency::LockGuard g(&pendingKeyLock); + if (!hasPendingKeyVerificationKey || node == 0 || node != pendingKeyVerificationNode) + return false; + out.size = 32; + memcpy(out.bytes, pendingKeyVerificationPublicKey, 32); + return true; +} + #endif concurrency::Lock *cryptLock; diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 2b0090d5c..95c7eb8ec 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -59,6 +59,17 @@ class CryptoEngine virtual bool setDHPublicKey(uint8_t *publicKey); virtual void hash(uint8_t *bytes, size_t numBytes); + // Temporary holder for a peer's not-yet-verified public key, learned in-band during an + // in-progress key-verification handshake before it is committed to NodeDB. Lets the Router + // run the DH handshake to encode/decode the follow-on PKI packet. Single slot is enough: + // only one verification runs at a time. Discarded when the handshake ends (resetToIdle). + // Internally guarded by pendingKeyLock, not cryptLock: the Router calls the getter while + // already holding the non-recursive cryptLock; KeyVerificationModule writes from elsewhere. + void setPendingPublicKey(uint32_t node, const uint8_t *key); + void clearPendingPublicKey(); + // Fills `out` (size set to 32) and returns true iff a pending key is held for `node`. + bool getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out); + virtual void aesSetKey(const uint8_t *key, size_t key_len); virtual void aesEncrypt(uint8_t *in, uint8_t *out); @@ -94,6 +105,10 @@ class CryptoEngine #if !(MESHTASTIC_EXCLUDE_PKI) uint8_t shared_key[32] = {0}; uint8_t private_key[32] = {0}; + uint32_t pendingKeyVerificationNode = 0; + uint8_t pendingKeyVerificationPublicKey[32] = {0}; + bool hasPendingKeyVerificationKey = false; + concurrency::Lock pendingKeyLock; #if !(MESHTASTIC_EXCLUDE_XEDDSA) uint8_t xeddsa_public_key[32] = {0}; uint8_t xeddsa_private_key[32] = {0}; diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index fb56b127f..ce356d7cd 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -11,12 +11,13 @@ #include "NodeDB.h" #include "Power.h" #include "PowerFSM.h" -#include "RTC.h" #include "TypeConversions.h" +#include "gps/RTC.h" #include "graphics/draw/MessageRenderer.h" #include "main.h" #include "mesh-pb-constants.h" #include "meshUtils.h" +#include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include "modules/PositionModule.h" #include "modules/RoutingModule.h" @@ -63,6 +64,7 @@ Allocator &clientNotificationPool = staticClientN Allocator &queueStatusPool = staticQueueStatusPool; +#include "PositionPrecision.h" #include "Router.h" MeshService::MeshService() @@ -173,6 +175,51 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id) return nodenum; } +#if MESHTASTIC_ENABLE_FRAME_INJECTION +// Deliver a client-supplied frame into the receive pipeline as if it arrived off the LoRa chip. Mirrors +// the portduino SimRadio SIMULATOR_APP unwrap so the same host wire format works on real hardware: the +// frame rides inside a Compressed envelope wrapped in a MeshPacket that carries from/to/id/channel. +// Compressed.portnum == UNKNOWN_APP -> Compressed.data is verbatim ciphertext, decrypted as if off-air +// otherwise -> Compressed.data is the plaintext payload for Compressed.portnum +void MeshService::injectAsReceived(meshtastic_MeshPacket &p) +{ + meshtastic_Compressed scratch; + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + memset(&scratch, 0, sizeof(scratch)); + if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_Compressed_msg, &scratch)) { + if (scratch.portnum == meshtastic_PortNum_UNKNOWN_APP) { + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + memcpy(p.encrypted.bytes, scratch.data.bytes, scratch.data.size); + p.encrypted.size = scratch.data.size; + } else { + memcpy(&p.decoded.payload, &scratch.data, sizeof(scratch.data)); + p.decoded.portnum = scratch.portnum; + } + } else { + LOG_ERROR("inject: could not decode Compressed envelope, dropping"); + return; + } + } + // The real RX path (RadioLibInterface::handleReceiveInterrupt) drops sender==0; mirror it so injection + // behaves identically to an over-the-air frame. + if (p.from == 0) { + LOG_WARN("inject: dropping frame with from==0 (matches real LoRa RX)"); + return; + } + meshtastic_MeshPacket *mp = packetPool.allocCopy(p); + if (!mp) + return; + if (mp->rx_snr == 0) // plausible synthetic link metadata unless the caller set it + mp->rx_snr = 8; + if (mp->rx_rssi == 0) + mp->rx_rssi = -40; + mp->rx_time = getValidTime(RTCQualityFromNet); + LOG_INFO("inject: RX from=0x%08x to=0x%08x id=0x%08x ch=%d %s", mp->from, mp->to, mp->id, mp->channel, + mp->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ? "encrypted" : "decoded"); + router->enqueueReceivedMessage(mp); +} +#endif + /** * Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh) * Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep a @@ -186,6 +233,16 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) SimRadio::instance->unpackAndReceive(p); return; } +#endif +#if MESHTASTIC_ENABLE_FRAME_INJECTION + // Real-hardware analog of the SimRadio path above: deliver a client-supplied frame into the RX + // pipeline exactly as if it had arrived off the LoRa chip. Reached before the p.from=0 line below, + // so an injected sender is preserved. Build-flag gated (off by default) - it lets anything with a + // wired connection forge over-the-air traffic, so it must never ship enabled. + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.portnum == meshtastic_PortNum_SIMULATOR_APP) { + injectAsReceived(p); + return; + } #endif p.from = 0; // We don't let clients assign nodenums to their sent messages p.next_hop = NO_NEXT_HOP_PREFERENCE; // We don't let clients assign next_hop to their sent messages @@ -203,6 +260,14 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) const StoredMessage &sm = messageStore.addFromPacket(p); graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI }) +#if !MESHTASTIC_EXCLUDE_ADMIN + // Note admin requests on their way out: AdminModule only accepts a response from a remote we + // actually asked. Runs before encryption, while the payload is still readable. + if (adminModule && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) + adminModule->noteOutgoingAdminRequest(p); +#endif + // Send the packet into the mesh DEBUG_HEAP_BEFORE; auto a = packetPool.allocCopy(p); @@ -293,8 +358,31 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) LOG_DEBUG("Skip position ping; no fresh position since boot"); return false; } - LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel); - positionModule->sendOurPosition(dest, wantReplies, node->channel); + // Prefer the node's current channel, but fall back to the first channel with + // position enabled (matching PositionModule::sendOurPosition() behavior). + uint8_t sendChan = node->channel; + if (getPositionPrecisionForChannel(sendChan) == 0) { + bool found = false; + for (uint8_t ch = 0; ch < 8; ++ch) { + if (getPositionPrecisionForChannel(ch) != 0) { + sendChan = ch; + found = true; + break; + } + } + if (!found) { + // No channel with position enabled: fall back to sending nodeinfo, as before. + if (nodeInfoModule) { + LOG_INFO( + "No channel with position enabled; sending nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", + dest, wantReplies, node->channel); + nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); + } + return false; + } + } + LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan); + positionModule->sendOurPosition(dest, wantReplies, sendChan); return true; } } else { diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 41dcf1c80..b529d2836 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -159,6 +159,12 @@ class MeshService */ void handleToRadio(meshtastic_MeshPacket &p); +#if MESHTASTIC_ENABLE_FRAME_INJECTION + /// Test/debug seam (build-flag gated, off by default): deliver a client-supplied frame into the + /// receive pipeline as if it had arrived off the LoRa chip. See the definition for the wire format. + void injectAsReceived(meshtastic_MeshPacket &p); +#endif + /** The radioConfig object just changed, call this to force the hw to change to the new settings * @return true if client devices should be sent a new set of radio configs */ diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b2804910c..4c2879adc 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -13,7 +13,6 @@ #include "NodeDB.h" #include "PacketHistory.h" #include "PowerFSM.h" -#include "RTC.h" #include "RadioInterface.h" #include "Router.h" #include "SPILock.h" @@ -21,12 +20,14 @@ #include "TransmitHistory.h" #include "TypeConversions.h" #include "error.h" +#include "gps/RTC.h" #include "main.h" #include "memory/MemAudit.h" #include "mesh-pb-constants.h" #include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" #include "meshUtils.h" #include "modules/NeighborInfoModule.h" +#include "target_specific.h" #if HAS_VARIABLE_HOPS #include "modules/HopScalingModule.h" #endif @@ -50,11 +51,7 @@ #include "SPILock.h" #include "modules/StoreForwardModule.h" #include -#include -#include #include -#include -#include #endif #ifdef ARCH_PORTDUINO @@ -372,8 +369,7 @@ void NodeDB::disarmNodeDatabaseDecodeTargets() */ uint32_t radioGeneration; -// FIXME - move this somewhere else -extern void getMacAddr(uint8_t *dmac); +// getMacAddr() and getDeviceId() are the per-architecture hooks declared in target_specific.h. /** * @@ -410,53 +406,13 @@ NodeDB::NodeDB() uint32_t channelFileCRC = crc32Buffer(&channelFile, sizeof(channelFile)); int saveWhat = 0; - // Get device unique id -#if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) - uint32_t unique_id[4]; - // ESP32 factory burns a unique id in efuse for S2+ series and evidently C3+ series - // This is used for HMACs in the esp-rainmaker AIOT platform and seems to be a good choice for us - esp_err_t err = esp_efuse_read_field_blob(ESP_EFUSE_OPTIONAL_UNIQUE_ID, unique_id, sizeof(unique_id) * 8); - if (err == ESP_OK) { - memcpy(myNodeInfo.device_id.bytes, unique_id, sizeof(unique_id)); - myNodeInfo.device_id.size = 16; - } else { - LOG_WARN("Failed to read unique id from efuse"); + // Re-read the device id from silicon each boot via the per-arch getDeviceId(); clear the + // disk-loaded value first so a failed/empty derivation leaves it unset rather than stale. + myNodeInfo.device_id.size = 0; + memset(myNodeInfo.device_id.bytes, 0, sizeof(myNodeInfo.device_id.bytes)); + if (getDeviceId(myNodeInfo.device_id.bytes)) { + myNodeInfo.device_id.size = sizeof(myNodeInfo.device_id.bytes); } -#elif defined(ARCH_NRF54L15) - // nRF54L15: DEVICEID is under FICR->INFO sub-struct (not top-level as on nRF52) - uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; - uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; - memcpy(myNodeInfo.device_id.bytes, &device_id_start, sizeof(device_id_start)); - memcpy(myNodeInfo.device_id.bytes + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); - myNodeInfo.device_id.size = 16; -#elif defined(ARCH_NRF52) - // Nordic applies a FIPS compliant Random ID to each chip at the factory - // We concatenate the device address to the Random ID to create a unique ID for now - // This will likely utilize a crypto module in the future - uint64_t device_id_start = ((uint64_t)NRF_FICR->DEVICEID[1] << 32) | NRF_FICR->DEVICEID[0]; - uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; - memcpy(myNodeInfo.device_id.bytes, &device_id_start, sizeof(device_id_start)); - memcpy(myNodeInfo.device_id.bytes + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); - myNodeInfo.device_id.size = 16; - // Uncomment below to print the device id -#elif ARCH_PORTDUINO - if (portduino_config.has_device_id) { - memcpy(myNodeInfo.device_id.bytes, portduino_config.device_id, 16); - myNodeInfo.device_id.size = 16; - } -#else - // FIXME - implement for other platforms -#endif - - // if (myNodeInfo.device_id.size == 16) { - // std::string deviceIdHex; - // for (size_t i = 0; i < myNodeInfo.device_id.size; ++i) { - // char buf[3]; - // snprintf(buf, sizeof(buf), "%02X", myNodeInfo.device_id.bytes[i]); - // deviceIdHex += buf; - // } - // LOG_DEBUG("Device ID (HEX): %s", deviceIdHex.c_str()); - // } // likewise - we always want the app requirements to come from the running appload myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00 @@ -522,6 +478,12 @@ NodeDB::NodeDB() LOG_DEBUG("Number of Device Reboots: %d", myNodeInfo.reboot_count); #endif + // UA_868 is obsolete; migrate to EU_868 before resetRadioConfig() below validates the region. + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UA_868) { + LOG_INFO("UA_868 region is obsolete, migrating saved config to EU_868"); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + } + resetRadioConfig(); // If bogus settings got saved, then fix them // nodeDB->LOG_DEBUG("region=%d, NODENUM=0x%x, dbsize=%d", config.lora.region, myNodeInfo.my_node_num, numMeshNodes); @@ -1564,16 +1526,34 @@ void NodeDB::initModuleConfigIntervals() moduleConfig.telemetry.device_update_interval = MAX_INTERVAL; #endif +#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED + moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED; +#endif + #ifdef USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL moduleConfig.telemetry.environment_update_interval = USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL; #else moduleConfig.telemetry.environment_update_interval = 0; #endif -#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED - moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED; +#ifdef USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED + moduleConfig.telemetry.environment_screen_enabled = USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED; #endif + +#ifdef USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL + moduleConfig.telemetry.air_quality_interval = USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL; +#else moduleConfig.telemetry.air_quality_interval = 0; +#endif + +#ifdef USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED + moduleConfig.telemetry.air_quality_enabled = USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED; +#endif + +#ifdef USERPREFS_CONFIG_AQ_SCREEN_ENABLED + moduleConfig.telemetry.air_quality_screen_enabled = USERPREFS_CONFIG_AQ_SCREEN_ENABLED; +#endif + moduleConfig.telemetry.power_update_interval = 0; moduleConfig.telemetry.health_update_interval = 0; moduleConfig.neighbor_info.update_interval = 0; @@ -1635,10 +1615,16 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum) removed++; } numMeshNodes -= removed; - std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.begin() + numMeshNodes + 1, - meshtastic_NodeInfoLite()); - if (removed) - eraseNodeSatellites(nodeNum); + if (removed) { + // Clear exactly the slots compaction vacated. Sizing this from `removed` (rather than a + // fixed one) keeps it inside the vector when nothing matched and the store is full. + const size_t first = numMeshNodes; + const size_t last = std::min(first + removed, nodeDatabase.nodes.size()); + std::fill(nodeDatabase.nodes.begin() + first, nodeDatabase.nodes.begin() + last, meshtastic_NodeInfoLite()); + } + // Drop the node's satellite stores and warm-tier copy regardless of which tier it lived in, so + // an explicit removal fully forgets it. + eraseNodeSatellites(nodeNum); #if WARM_NODE_COUNT > 0 // Explicit user removal: don't let the warm tier resurrect the node warmStore.remove(nodeNum); @@ -1896,7 +1882,8 @@ void NodeDB::cleanupMeshDB() // Keep any key we learned (e.g. via a DM before the NodeInfo // exchange completed) rather than losing it with the purge. if (n.public_key.size == 32) - warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n)); + warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n), + nodeInfoLiteHasXeddsaSigned(&n)); #endif eraseNodeSatellites(gone); @@ -2080,7 +2067,7 @@ void NodeDB::demoteOldestHotNodesToWarm() // Keep the public key if we have one (40 B warm record); keyless nodes // still get a placeholder so re-admission restores last_heard. warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role, - warmProtectedCategory(n)); + warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n)); // Demotion drops the node from the header table, so drop its satellites // too (the eviction chokepoint) - they'd otherwise orphan until the next // enforceSatelliteCaps pass. @@ -3772,7 +3759,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) // Demote to the warm tier so the identity (and crucially the // PKI key) outlives the hot-store slot. warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL, - evicted.role, warmProtectedCategory(evicted)); + evicted.role, warmProtectedCategory(evicted), nodeInfoLiteHasXeddsaSigned(&evicted)); #endif eraseNodeSatellites(evicted.num); // Shove the remaining nodes down the chain @@ -3801,10 +3788,13 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) // Re-admission: restore what the warm tier kept for this node WarmNodeEntry warm; if (warmStore.take(n, warm)) { - lite->last_heard = warmTimeOf(warm); // mask off the stolen role/protected metadata bits + lite->last_heard = warmTimeOf(warm); // mask off the stolen metadata bits // Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT // until the next NodeInfo arrives. lite->role = static_cast(warmRoleOf(warm)); + // Restore the signer bit too: it is learned from verified traffic, not from + // NodeInfo, so a round trip through the warm tier must not relearn it from zero. + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, warmSignerOf(warm)); if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) { lite->public_key.size = 32; memcpy(lite->public_key.bytes, warm.public_key, 32); diff --git a/src/mesh/PacketCache.cpp b/src/mesh/PacketCache.cpp deleted file mode 100644 index 0edf81741..000000000 --- a/src/mesh/PacketCache.cpp +++ /dev/null @@ -1,253 +0,0 @@ -#include "PacketCache.h" -#include "Router.h" - -PacketCache packetCache{}; - -/** - * Allocate a new cache entry and copy the packet header and payload into it - */ -PacketCacheEntry *PacketCache::cache(const meshtastic_MeshPacket *p, bool preserveMetadata) -{ - size_t payload_size = - (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag) ? p->encrypted.size : p->decoded.payload.size; - PacketCacheEntry *e = (PacketCacheEntry *)malloc(sizeof(PacketCacheEntry) + payload_size + - (preserveMetadata ? sizeof(PacketCacheMetadata) : 0)); - if (!e) { - LOG_ERROR("Unable to allocate memory for packet cache entry"); - return NULL; - } - - *e = {}; - e->header.from = p->from; - e->header.to = p->to; - e->header.id = p->id; - e->header.channel = p->channel; - e->header.next_hop = p->next_hop; - e->header.relay_node = p->relay_node; - e->header.flags = (p->hop_limit & PACKET_FLAGS_HOP_LIMIT_MASK) | (p->want_ack ? PACKET_FLAGS_WANT_ACK_MASK : 0) | - (p->via_mqtt ? PACKET_FLAGS_VIA_MQTT_MASK : 0) | - ((p->hop_start << PACKET_FLAGS_HOP_START_SHIFT) & PACKET_FLAGS_HOP_START_MASK); - - PacketCacheMetadata m{}; - if (preserveMetadata) { - e->has_metadata = true; - m.rx_rssi = (uint8_t)(p->rx_rssi + 200); - m.rx_snr = (uint8_t)((p->rx_snr + 30.0f) / 0.25f); - m.rx_time = p->rx_time; - m.transport_mechanism = p->transport_mechanism; - m.priority = p->priority; - } - if (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag) { - e->encrypted = true; - e->payload_len = p->encrypted.size; - memcpy(((unsigned char *)e) + sizeof(PacketCacheEntry), p->encrypted.bytes, p->encrypted.size); - } else if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { - e->encrypted = false; - if (preserveMetadata) { - m.portnum = p->decoded.portnum; - m.want_response = p->decoded.want_response; - m.emoji = p->decoded.emoji; - m.bitfield = p->decoded.bitfield; - if (p->decoded.reply_id) - m.reply_id = p->decoded.reply_id; - else if (p->decoded.request_id) - m.request_id = p->decoded.request_id; - } - e->payload_len = p->decoded.payload.size; - memcpy(((unsigned char *)e) + sizeof(PacketCacheEntry), p->decoded.payload.bytes, p->decoded.payload.size); - } else { - LOG_ERROR("Unable to cache packet with unknown payload type %d", p->which_payload_variant); - free(e); - return NULL; - } - if (preserveMetadata) - memcpy(((unsigned char *)e) + sizeof(PacketCacheEntry) + e->payload_len, &m, sizeof(m)); - - size += sizeof(PacketCacheEntry) + e->payload_len + (e->has_metadata ? sizeof(PacketCacheMetadata) : 0); - insert(e); - return e; -}; - -/** - * Dump a list of packets into the provided buffer - */ -void PacketCache::dump(void *dest, const PacketCacheEntry **entries, size_t num_entries) -{ - unsigned char *pos = (unsigned char *)dest; - for (size_t i = 0; i < num_entries; i++) { - size_t entry_len = - sizeof(PacketCacheEntry) + entries[i]->payload_len + (entries[i]->has_metadata ? sizeof(PacketCacheMetadata) : 0); - memcpy(pos, entries[i], entry_len); - pos += entry_len; - } -} - -/** - * Calculate the length of buffer needed to dump the specified entries - */ -size_t PacketCache::dumpSize(const PacketCacheEntry **entries, size_t num_entries) -{ - size_t total_size = 0; - for (size_t i = 0; i < num_entries; i++) { - total_size += sizeof(PacketCacheEntry) + entries[i]->payload_len; - if (entries[i]->has_metadata) - total_size += sizeof(PacketCacheMetadata); - } - return total_size; -} - -/** - * Find a packet in the cache - */ -PacketCacheEntry *PacketCache::find(NodeNum from, PacketId id) -{ - uint16_t h = PACKET_HASH(from, id); - PacketCacheEntry *e = buckets[PACKET_CACHE_BUCKET(h)]; - while (e) { - if (e->header.from == from && e->header.id == id) - return e; - e = e->next; - } - return NULL; -} - -/** - * Find a packet in the cache by its hash - */ -PacketCacheEntry *PacketCache::find(PacketHash h) -{ - PacketCacheEntry *e = buckets[PACKET_CACHE_BUCKET(h)]; - while (e) { - if (PACKET_HASH(e->header.from, e->header.id) == h) - return e; - e = e->next; - } - return NULL; -} - -/** - * Load a list of packets from the provided buffer - */ -bool PacketCache::load(void *src, PacketCacheEntry **entries, size_t num_entries) -{ - memset(entries, 0, sizeof(PacketCacheEntry *) * num_entries); - unsigned char *pos = (unsigned char *)src; - for (size_t i = 0; i < num_entries; i++) { - PacketCacheEntry e{}; - memcpy(&e, pos, sizeof(PacketCacheEntry)); - size_t entry_len = sizeof(PacketCacheEntry) + e.payload_len + (e.has_metadata ? sizeof(PacketCacheMetadata) : 0); - entries[i] = (PacketCacheEntry *)malloc(entry_len); - size += entry_len; - if (!entries[i]) { - LOG_ERROR("Unable to allocate memory for packet cache entry"); - for (size_t j = 0; j < i; j++) { - size -= sizeof(PacketCacheEntry) + entries[j]->payload_len + - (entries[j]->has_metadata ? sizeof(PacketCacheMetadata) : 0); - free(entries[j]); - entries[j] = NULL; - } - return false; - } - memcpy(entries[i], pos, entry_len); - pos += entry_len; - } - for (size_t i = 0; i < num_entries; i++) - insert(entries[i]); - return true; -} - -/** - * Copy the cached packet into the provided MeshPacket structure - */ -void PacketCache::rehydrate(const PacketCacheEntry *e, meshtastic_MeshPacket *p) -{ - if (!e || !p) - return; - - *p = {}; - p->from = e->header.from; - p->to = e->header.to; - p->id = e->header.id; - p->channel = e->header.channel; - p->next_hop = e->header.next_hop; - p->relay_node = e->header.relay_node; - p->hop_limit = e->header.flags & PACKET_FLAGS_HOP_LIMIT_MASK; - p->want_ack = !!(e->header.flags & PACKET_FLAGS_WANT_ACK_MASK); - p->via_mqtt = !!(e->header.flags & PACKET_FLAGS_VIA_MQTT_MASK); - p->hop_start = (e->header.flags & PACKET_FLAGS_HOP_START_MASK) >> PACKET_FLAGS_HOP_START_SHIFT; - p->which_payload_variant = e->encrypted ? meshtastic_MeshPacket_encrypted_tag : meshtastic_MeshPacket_decoded_tag; - - unsigned char *payload = ((unsigned char *)e) + sizeof(PacketCacheEntry); - PacketCacheMetadata m{}; - if (e->has_metadata) { - memcpy(&m, (payload + e->payload_len), sizeof(m)); - p->rx_rssi = ((int)m.rx_rssi) - 200; - p->rx_snr = ((float)m.rx_snr * 0.25f) - 30.0f; - p->rx_time = m.rx_time; - p->transport_mechanism = (meshtastic_MeshPacket_TransportMechanism)m.transport_mechanism; - p->priority = (meshtastic_MeshPacket_Priority)m.priority; - } - if (e->encrypted) { - memcpy(p->encrypted.bytes, payload, e->payload_len); - p->encrypted.size = e->payload_len; - } else { - memcpy(p->decoded.payload.bytes, payload, e->payload_len); - p->decoded.payload.size = e->payload_len; - if (e->has_metadata) { - // Decrypted-only metadata - p->decoded.portnum = (meshtastic_PortNum)m.portnum; - p->decoded.want_response = m.want_response; - p->decoded.emoji = m.emoji; - p->decoded.bitfield = m.bitfield; - if (m.reply_id) - p->decoded.reply_id = m.reply_id; - else if (m.request_id) - p->decoded.request_id = m.request_id; - } - } -} - -/** - * Release a cache entry - */ -void PacketCache::release(PacketCacheEntry *e) -{ - if (!e) - return; - remove(e); - size -= sizeof(PacketCacheEntry) + e->payload_len + (e->has_metadata ? sizeof(PacketCacheMetadata) : 0); - free(e); -} - -/** - * Insert a new entry into the hash table - */ -void PacketCache::insert(PacketCacheEntry *e) -{ - assert(e); - PacketHash h = PACKET_HASH(e->header.from, e->header.id); - PacketCacheEntry **target = &buckets[PACKET_CACHE_BUCKET(h)]; - e->next = *target; - *target = e; - num_entries++; -} - -/** - * Remove an entry from the hash table - */ -void PacketCache::remove(PacketCacheEntry *e) -{ - assert(e); - PacketHash h = PACKET_HASH(e->header.from, e->header.id); - PacketCacheEntry **target = &buckets[PACKET_CACHE_BUCKET(h)]; - while (*target) { - if (*target == e) { - *target = e->next; - e->next = NULL; - num_entries--; - break; - } else { - target = &(*target)->next; - } - } -} \ No newline at end of file diff --git a/src/mesh/PacketCache.h b/src/mesh/PacketCache.h deleted file mode 100644 index 85660922b..000000000 --- a/src/mesh/PacketCache.h +++ /dev/null @@ -1,75 +0,0 @@ -#pragma once -#include "RadioInterface.h" - -#define PACKET_HASH(a, b) ((((a ^ b) >> 16) ^ (a ^ b)) & 0xFFFF) // 16 bit fold of packet (from, id) tuple -typedef uint16_t PacketHash; - -#define PACKET_CACHE_BUCKETS 64 // Number of hash table buckets -#define PACKET_CACHE_BUCKET(h) (((h >> 12) ^ (h >> 6) ^ h) & 0x3F) // Fold hash down to 6-bit bucket index - -typedef struct PacketCacheEntry { - PacketCacheEntry *next; - PacketHeader header; - uint16_t payload_len = 0; - union { - uint16_t bitfield; - struct { - uint8_t encrypted : 1; // Payload is encrypted - uint8_t has_metadata : 1; // Payload includes PacketCacheMetadata - uint8_t : 6; // Reserved for future use - uint8_t : 8; // Reserved for future use - }; - }; -} PacketCacheEntry; - -typedef struct PacketCacheMetadata { - PacketCacheMetadata() : _bitfield(0), reply_id(0), _bitfield2(0) {} - union { - uint32_t _bitfield; - struct { - uint16_t portnum : 9; // meshtastic_MeshPacket::decoded::portnum - uint16_t want_response : 1; // meshtastic_MeshPacket::decoded::want_response - uint16_t emoji : 1; // meshtastic_MeshPacket::decoded::emoji - uint16_t bitfield : 5; // meshtastic_MeshPacket::decoded::bitfield (truncated) - uint8_t rx_rssi : 8; // meshtastic_MeshPacket::rx_rssi (map via actual RSSI + 200) - uint8_t rx_snr : 8; // meshtastic_MeshPacket::rx_snr (map via (p->rx_snr + 30.0f) / 0.25f) - }; - }; - union { - uint32_t reply_id; // meshtastic_MeshPacket::decoded.reply_id - uint32_t request_id; // meshtastic_MeshPacket::decoded.request_id - }; - uint32_t rx_time = 0; // meshtastic_MeshPacket::rx_time - uint8_t transport_mechanism = 0; // meshtastic_MeshPacket::transport_mechanism - struct { - uint8_t _bitfield2; - union { - uint8_t priority : 7; // meshtastic_MeshPacket::priority - uint8_t reserved : 1; // Reserved for future use - }; - }; -} PacketCacheMetadata; - -class PacketCache -{ - public: - PacketCacheEntry *cache(const meshtastic_MeshPacket *p, bool preserveMetadata); - static void dump(void *dest, const PacketCacheEntry **entries, size_t num_entries); - size_t dumpSize(const PacketCacheEntry **entries, size_t num_entries); - PacketCacheEntry *find(NodeNum from, PacketId id); - PacketCacheEntry *find(PacketHash h); - bool load(void *src, PacketCacheEntry **entries, size_t num_entries); - size_t getNumEntries() { return num_entries; } - size_t getSize() { return size; } - void rehydrate(const PacketCacheEntry *e, meshtastic_MeshPacket *p); - void release(PacketCacheEntry *e); - - private: - PacketCacheEntry *buckets[PACKET_CACHE_BUCKETS]{}; - size_t num_entries = 0; - size_t size = 0; - void insert(PacketCacheEntry *e); - void remove(PacketCacheEntry *e); -}; - -extern PacketCache packetCache; \ No newline at end of file diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 8fac33e84..f42e3dbb3 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -38,7 +38,7 @@ #include "mqtt/MQTT.h" #endif #include "Throttle.h" -#include +#include "gps/RTC.h" namespace { @@ -1201,19 +1201,49 @@ void PhoneAPI::prefetchNodeInfos() onNowHasData(0); } +namespace +{ +/// Derive a stable id for a replayed satellite-DB record. Unchanged history replayed on +/// every reconnect must not look like a new packet to the phone's history/dedup - the id +/// only changes when the underlying data (its timestamp) actually changes. +// `kind` only needs to distinguish the record types that can otherwise collide (e.g. device +// vs. environment metrics both replay as TELEMETRY_APP with the same node/last_heard) - pass +// the payload's own variant/port constant. +uint32_t makeReplayPacketId(NodeNum num, uint32_t timestamp, uint32_t kind) +{ + uint32_t h = num; + h = h * 2654435761u + timestamp; + h = h * 2654435761u + kind; + return h ? h : 1; // some clients treat id 0 as "unset" +} + +/// Populate hop_start/hop_limit from the node's real last-known hop count (if any) so a +/// replayed packet doesn't read as "heard directly" when it wasn't. +void setReplayHopFields(meshtastic_MeshPacket &pkt, const meshtastic_NodeInfoLite *header) +{ + uint8_t hopLimit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); + uint8_t hopsAway = (header && header->has_hops_away) ? header->hops_away : 0; + pkt.hop_start = hopLimit; + pkt.hop_limit = hopsAway < hopLimit ? (uint8_t)(hopLimit - hopsAway) : 0; +} +} // namespace + meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos) { // Shape this exactly like a fresh live broadcast Position from the peer so the // phone runs it through its normal "live position broadcast" handler path. // to=ourNum would read as a DM-from-peer and never lands in node detail UI. meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); pkt.rx_time = pos.time; + // Stable per-node/per-fix id: replaying the same unchanged history on every + // reconnect must not look like a brand new packet to the phone's history/dedup. + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; @@ -1231,13 +1261,13 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); // No native timestamp on telemetry packets here; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - iOS client filters // TRANSPORT_INTERNAL packets out of broadcast peer state updates. @@ -1337,12 +1367,12 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - iOS client filters // TRANSPORT_INTERNAL packets out of broadcast peer state updates. @@ -1400,13 +1430,13 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); // StatusMessage has no native timestamp; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - client filters pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; @@ -1667,6 +1697,19 @@ bool PhoneAPI::wasSeenRecently(uint32_t id) return false; } +PhoneAPI::LocalAdminGate PhoneAPI::classifyLocalAdminPacket(const meshtastic_MeshPacket &p, bool adminAuthorized, + meshtastic_AdminMessage &outAdmin) +{ + if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP) + return LocalAdminGate::NotAdmin; + outAdmin = meshtastic_AdminMessage_init_zero; + if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &outAdmin)) + return LocalAdminGate::NotAdmin; // undecodable: let the normal reject path respond + if (outAdmin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) + return LocalAdminGate::LockdownAuth; // the passphrase itself; authenticate regardless of prior state + return adminAuthorized ? LocalAdminGate::AuthorizedPassThrough : LocalAdminGate::DropUnauthorized; +} + /** * Handle a packet that the phone wants us to send. It is our responsibility to free the packet to the pool */ @@ -1691,26 +1734,33 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p) // the gate here closes that race and covers H6/H7 from the // audit: get_config_request and set_config from unauthed // clients no longer reach AdminModule at all. - if (p.from == 0 && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && - p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) { + // Gate on the connection, not the wire `from`, which a client can forge to a non-zero value to + // bypass the check before MeshService normalizes it back to a local identity. + // Scope the decoded admin message: it holds the plaintext passphrase, so bound its lifetime. + { meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero; - if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) { - if (admin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) { - handleLockdownAuthInline(admin.lockdown_auth); - // Wipe the decoded passphrase scratch - the byte array in - // p.decoded.payload.bytes is wiped by handleLockdownAuthInline. - volatile uint8_t *adminVol = const_cast(admin.lockdown_auth.passphrase.bytes); - for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++) - adminVol[i] = 0; - return true; - } - if (!getAdminAuthorized()) { - LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant); - return false; - } + switch (classifyLocalAdminPacket(p, getAdminAuthorized(), admin)) { + case LocalAdminGate::LockdownAuth: { + handleLockdownAuthInline(admin.lockdown_auth); + // The encoded wipe is the security-critical one: nothing else clears the passphrase from + // the packet buffer. handleLockdownAuthInline already zeroes the decoded copy up to its + // size; re-zero the full scratch capacity here as defense in depth. + volatile uint8_t *adminVol = const_cast(admin.lockdown_auth.passphrase.bytes); + for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++) + adminVol[i] = 0; + volatile uint8_t *encodedVol = const_cast(p.decoded.payload.bytes); + for (size_t i = 0; i < sizeof(p.decoded.payload.bytes); i++) + encodedVol[i] = 0; + p.decoded.payload.size = 0; // keep the length consistent with the wiped buffer + return true; + } + case LocalAdminGate::DropUnauthorized: + LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant); + return false; + case LocalAdminGate::NotAdmin: + case LocalAdminGate::AuthorizedPassThrough: + break; // normal handling } - // pb_decode failure: fall through to normal handling so the - // regular Router/AdminModule reject path can respond. } #endif diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index ba7b13567..ab04c178b 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -319,4 +319,18 @@ class PhoneAPI /// If the mesh service tells us fromNum has changed, tell the phone virtual int onNotify(uint32_t newValue) override; + + public: + /// How the lockdown admin gate should treat a phone->radio packet. + enum class LocalAdminGate { + NotAdmin, ///< Not a decodable ADMIN_APP payload; normal handling. + LockdownAuth, ///< A lockdown_auth payload; authenticate the connection inline. + DropUnauthorized, ///< Admin payload from a connection that has not authenticated; drop. + AuthorizedPassThrough, ///< Admin payload from an authorized connection; normal handling. + }; + + /// Classify a phone->radio packet for the lockdown admin gate, ignoring the wire `from` (which a + /// client can forge) and deciding on the connection's authorization. Fills outAdmin for lockdown. + static LocalAdminGate classifyLocalAdminPacket(const meshtastic_MeshPacket &p, bool adminAuthorized, + meshtastic_AdminMessage &outAdmin); }; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 84d0f7906..b577a7f35 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -184,11 +184,9 @@ const RegionInfo regions[] = { /* 433,05-434,7 Mhz 10 mW - 868,0-868,6 Mhz 25 mW - https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf + https://zakon.rada.gov.ua/laws/show/262-2026-п */ RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0), - RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD, PRESET(LONG_FAST), 0), /* Malaysia diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 0641cbc3c..9b1cc3ff2 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -5,7 +5,7 @@ #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" -#include "RTC.h" +#include "gps/RTC.h" #include "configuration.h" #include "main.h" @@ -452,7 +452,7 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout } #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) -bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) +bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) { // Only a signature we verify below may mark this packet signed; never trust an inbound flag. p->xeddsa_signed = false; @@ -483,17 +483,17 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) return false; } else { // Truly unsigned (signature size 0) - only reject the class a signing node always signs: a - // non-PKI broadcast whose signed encoding would still fit the LoRa frame. encodedDataSize is - // the size of the encoded Data exactly as the sender built it (or 0 to size p->decoded - // canonically); with no signature field present it is the unsigned base, and adding - // XEDDSA_SIGNATURE_FIELD_BYTES mirrors the sender-side signedDataFits() gate per packet, - // whatever fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a - // signature are never signed, so they must not be hard-failed here even for a known signer. + // non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded + // canonically so this counts the same fields the sender's signedDataFits() gate counted; + // adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever + // fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature + // are never signed, so they must not be hard-failed here even for a known signer. const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) { - if (encodedDataSize == 0 && !pb_get_encoded_size(&encodedDataSize, &meshtastic_Data_msg, &p->decoded)) + size_t canonicalSize; + if (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded)) return true; // can't size it; never drop on a sizing failure - if (encodedDataSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { + if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from); return false; } @@ -524,36 +524,60 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) bool decrypted = false; ChannelIndex chIndex = 0; #if !(MESHTASTIC_EXCLUDE_PKI) - // Attempt PKI decryption first. The sender's key may come from the hot - // store or the warm tier (nodes evicted from the hot store keep their key - // there), so DMs from long-tail nodes still decrypt. - meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}}; - if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) && - nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 && - rawSize > MESHTASTIC_PKC_OVERHEAD) { - LOG_DEBUG("Attempt PKI decryption"); + // Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else + // fall back to a not-yet-committed key held during an in-progress key-verification handshake. + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic); - if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) { + meshtastic_NodeInfoLite *ourNode = nullptr; + if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD && + (ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) { + // Try the sender's known key first, then each configured admin key so an authorized admin can + // reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates. + bool viaAdminKey = false; + if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + } + for (int i = 0; i < 3 && !decrypted; i++) { + if (config.security.admin_key[i].size != 32) + continue; + remotePublic.size = 32; + memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); + + if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + viaAdminKey = true; + break; // stop after first successful decryption + } + } + if (decrypted) { LOG_INFO("PKI Decryption worked!"); - meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); - rawSize -= MESHTASTIC_PKC_OVERHEAD; - if (pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp) && + size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD; + if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) && decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) { decrypted = true; + rawSize = payloadSize; // commit the overhead subtraction only on full success LOG_INFO("Packet decrypted using PKI!"); p->pki_encrypted = true; - memcpy(p->public_key.bytes, fromKey.bytes, 32); + memcpy(p->public_key.bytes, remotePublic.bytes, 32); p->public_key.size = 32; p->decoded = decodedtmp; p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded + if (viaAdminKey) { + // Persist the admin key for the sender so future packets take the fast path and we can + // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it. + meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from); + if (fromNode != nullptr) + fromNode->public_key = remotePublic; + } } else { + // AEAD already authenticated this ciphertext, so no other candidate could decode it - + // the payload is simply malformed. LOG_ERROR("PKC Decrypted, but pb_decode failed!"); return DecodeState::DECODE_FAILURE; } - } else { - LOG_WARN("PKC decrypt attempted but failed!"); } } #endif @@ -597,17 +621,17 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (decrypted) { // parsing was successful p->channel = chIndex; // change to store the index instead of the hash - if (p->decoded.has_bitfield) - p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) - // rawSize is the size of the encoded Data exactly as the sender built it (the PKI branch's - // MESHTASTIC_PKC_OVERHEAD subtraction preserves that, and PKI packets are unicast so the - // downgrade predicate ignores them anyway). - if (!checkXeddsaReceivePolicy(p, rawSize)) + // Runs before the bitfield merge below: that merge can set want_response, adding wire bytes + // the sender never encoded and skewing the policy's sizing of p->decoded. + if (!checkXeddsaReceivePolicy(p)) return DecodeState::DECODE_FAILURE; #endif + if (p->decoded.has_bitfield) + p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; + /* Not actually ever used. // Decompress if needed. jm if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) { @@ -729,7 +753,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) LOG_DEBUG("Original length - %d ", p->decoded.payload.size); LOG_DEBUG("Compressed length - %d ", compressed_len); - LOG_DEBUG("Original message - %s ", p->decoded.payload.bytes); + LOG_DEBUG("Original message - %.*s ", (int)p->decoded.payload.size, p->decoded.payload.bytes); // If the compressed length is greater than or equal to the original size, don't use the compressed form if (compressed_len >= p->decoded.payload.size) { @@ -758,10 +782,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it #if !(MESHTASTIC_EXCLUDE_PKI) - // Destination key from the hot store or the warm tier (evicted - // long-tail nodes keep their key there) + // Resolve the destination's public key: prefer NodeDB (hot store or warm tier - evicted + // long-tail nodes keep their key there), otherwise (for a key-verification follow-on packet + // that explicitly requested PKI) fall back to the not-yet-verified key held during an + // in-progress handshake. This lets us DH-encode the follow-on packet before the peer's key + // has been committed to NodeDB. meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}}; bool haveDestKey = nodeDB->copyPublicKey(p->to, destKey); + if (!haveDestKey && p->pki_encrypted && p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && + crypto->getPendingPublicKey(p->to, destKey)) { + haveDestKey = true; + } // We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node // is not in the local nodedb // First, only PKC encrypt packets we are originating @@ -779,11 +810,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) config.security.private_key.size == 32 && !isBroadcast(p->to) && // Some portnums either make no sense to send with PKC p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP && - p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP) { + p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP && + // We allow Key Verification messages to be sent without a known destination key, since the point of those messages is + // to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet + // uses the pending key resolved into haveDestKey/destKey above. + // Though possible the first packet each direction should go non-pkc + // to handle the case where the remote node has our key, but we don't have theirs. + !(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) { LOG_DEBUG("Use PKI!"); if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN) return meshtastic_Routing_Error_TOO_LARGE; - // Check for a known public key for the destination + // Check for a usable public key for the destination (NodeDB or a pending key-verification key) if (!haveDestKey) { LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to, p->decoded.portnum); diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 954b88f24..d5d4b76ad 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -183,16 +183,15 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p); * downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would * still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass). * - * encodedDataSize is the wire size of the encoded Data as the sender built it; pass 0 to size - * p->decoded canonically instead (for already-decoded ingress such as plaintext-MQTT downlink, - * which bypasses perhapsDecode's crypto path). + * The fit test sizes p->decoded with the real encoder, so it measures the fields the sender + * encoded rather than any raw wire length. * * The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache. * (perhapsDecode already holds it; other call sites must take it themselves.) * * @return false if the packet must be dropped. */ -bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize = 0); +bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p); #endif extern Router *router; diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index ae4c485dd..817134b58 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -173,30 +173,6 @@ template bool SX126xInterface::init() LOG_WARN("Failed to apply SX1262 register 0x8B5 patch for RX improvement"); } -#if 0 - // Read/write a register we are not using (only used for FSK mode) to test SPI comms - uint8_t crcLSB = 0; - int err = lora.readRegister(SX126X_REG_CRC_POLYNOMIAL_LSB, &crcLSB, 1); - if(err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - //if(crcLSB != 0x0f) - // RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - crcLSB = 0x5a; - err = lora.writeRegister(SX126X_REG_CRC_POLYNOMIAL_LSB, &crcLSB, 1); - if(err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - err = lora.readRegister(SX126X_REG_CRC_POLYNOMIAL_LSB, &crcLSB, 1); - if(err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - if(crcLSB != 0x5a) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - // If we got this far register accesses (and therefore SPI comms) are good -#endif - if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(RADIOLIB_SX126X_LORA_CRC_ON); diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index 12649e031..5dd1ef99b 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -1,9 +1,9 @@ #include "StreamAPI.h" #include "PowerFSM.h" -#include "RTC.h" #include "Throttle.h" #include "concurrency/LockGuard.h" #include "configuration.h" +#include "gps/RTC.h" #define START1 0x94 #define START2 0xc3 diff --git a/src/mesh/TransmitHistory.cpp b/src/mesh/TransmitHistory.cpp index 9238942ac..35144ec0d 100644 --- a/src/mesh/TransmitHistory.cpp +++ b/src/mesh/TransmitHistory.cpp @@ -1,7 +1,7 @@ #include "TransmitHistory.h" #include "FSCommon.h" -#include "RTC.h" #include "SPILock.h" +#include "gps/RTC.h" #include #ifdef FSCom diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index 550549ad5..cce88e6b0 100644 --- a/src/mesh/WarmNodeStore.cpp +++ b/src/mesh/WarmNodeStore.cpp @@ -13,12 +13,11 @@ #if defined(NRF52840_XXAA) #include "flash/flash_nrf5x.h" -#define WARM_RING_MAGIC 0x324E5257u // "WRN2" - v2: last_heard low bits carry role + protected category +#define WARM_RING_MAGIC 0x334E5257u // "WRN3" - v3: last_heard low bits carry role + protected + signer +#define WARM_RING_MAGIC_V2 0x324E5257u // "WRN2" - v2: role + protected only; bit 6 was still timestamp. #define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" - v1: last_heard was a plain timestamp. -// v1 pages are still read on upgrade: we keep each record's identity + public key but -// DISCARD its last_heard (the old timestamp would be misread as role/protected bits). -// Records re-rank and re-learn their role on the next contact. Legacy pages convert to -// v2 naturally as the ring rotates. +// Older pages are still read on upgrade: v1 keeps identity + key but discards last_heard, +// v2 keeps the word but clears bit 6, which was still part of the timestamp then. // A tombstone is an entry record whose last_heard is all-ones - getTime() // (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is // detected via num == 0xFFFFFFFF before last_heard is ever inspected. @@ -34,10 +33,13 @@ struct WarmStoreHeader { }; static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format"); -#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" - v2: last_heard low bits carry role + protected category +#define WARM_STORE_MAGIC 0x334D5257u // "WRM3" - v3: last_heard low bits carry role + protected + signer +#define WARM_STORE_MAGIC_V2 \ + 0x324D5257u // "WRM2" - v2: role + protected only; bit 6 was still timestamp. On upgrade + // we clear the signer bit, then rewrite as v3. #define WARM_STORE_MAGIC_V1 \ 0x314D5257u // "WRM1" - v1: last_heard was a plain timestamp. On upgrade we keep - // identity + key but discard last_heard, then rewrite as v2. + // identity + key but discard last_heard, then rewrite as v3. #ifdef FSCom static const char *warmFileName = "/prefs/warm.dat"; @@ -134,18 +136,18 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8 return slot; } -bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat) +bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat, bool signer) { - // Pack role + protected category into the low bits of last_heard. place() and ring - // replay store the raw word verbatim, so the metadata round-trips through flash. - const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat); + // Pack role + protected category + signer into the low bits of last_heard. place() and + // ring replay store the raw word verbatim, so the metadata round-trips through flash. + const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat, signer); const WarmNodeEntry *slot = place(num, packed, key32); if (!slot) return false; persistEntry(*slot); - LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num, + LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u signer=%u (now %u/%u)", (unsigned)num, keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat, - (unsigned)count(), (unsigned)capacity()); + signer ? 1u : 0u, (unsigned)count(), (unsigned)capacity()); return true; } @@ -258,19 +260,24 @@ bool WarmNodeStore::saveIfDirty() // (stranded live entries re-appended, then erased). Flash access holds spiLock - // the page cache is shared with InternalFS/LittleFS. -bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const +bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt) const { flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h)); if (h.seq == 0xFFFFFFFFu) return false; // erased page if (h.magic == WARM_RING_MAGIC) { - if (legacy) - *legacy = false; + if (fmt) + *fmt = WarmFormat::Current; + return true; + } + if (h.magic == WARM_RING_MAGIC_V2) { + if (fmt) + *fmt = WarmFormat::V2; // replay it, but clear the signer bit (see WARM_RING_MAGIC_V2) return true; } if (h.magic == WARM_RING_MAGIC_V1) { - if (legacy) - *legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1) + if (fmt) + *fmt = WarmFormat::V1; // replay it, but discard last_heard (see WARM_RING_MAGIC_V1) return true; } return false; @@ -386,13 +393,13 @@ void WarmNodeStore::load() // Order valid pages by ascending seq so replay applies oldest first uint8_t order[WARM_FLASH_PAGES] = {}; uint32_t seqs[WARM_FLASH_PAGES] = {}; - bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay + WarmFormat fmtOf[WARM_FLASH_PAGES] = {}; // per-page on-flash format; older ones are normalised on replay uint8_t nValid = 0; uint8_t nCorrupt = 0; for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) { WarmPageHeader h; - bool legacy = false; - if (!ringReadHeader(p, h, &legacy)) { + WarmFormat fmt = WarmFormat::Current; + if (!ringReadHeader(p, h, &fmt)) { // An erased page reads back all-ones; any other magic is a // partially-written or bit-rotted header we're dropping, so flag it // rather than silently treating the loss as a clean empty ring. @@ -400,7 +407,7 @@ void WarmNodeStore::load() nCorrupt++; continue; } - legacyOf[p] = legacy; + fmtOf[p] = fmt; uint8_t pos = nValid; while (pos > 0 && static_cast(h.seq - seqs[pos - 1]) < 0) { order[pos] = order[pos - 1]; @@ -427,7 +434,7 @@ void WarmNodeStore::load() uint32_t migrated = 0; for (uint8_t k = 0; k < nValid; k++) { const uint8_t p = order[k]; - const bool legacy = legacyOf[p]; + const WarmFormat fmt = fmtOf[p]; uint16_t slot = 0; for (; slot < kRecordsPerPage; slot++) { WarmNodeEntry rec; @@ -444,12 +451,15 @@ void WarmNodeStore::load() memset(e, 0, sizeof(*e)); } } else { - // v1 (legacy) record: keep identity + key, but discard the old timestamp - - // its low bits would otherwise be misread as role/protected metadata. + // Normalise older records: v1's timestamp would be misread as role/protected, + // and v2's bit 6 as a signer we never verified. uint32_t lh = rec.last_heard; - if (legacy) { + if (fmt == WarmFormat::V1) { lh = 0; migrated++; + } else if (fmt == WarmFormat::V2) { + lh &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); + migrated++; } const WarmNodeEntry *e = place(rec.num, lh, rec.public_key); if (e) @@ -460,17 +470,16 @@ void WarmNodeStore::load() activePage = p; writeSlot = slot; nextSeq = seqs[k] + 1; - // If the head is a v1 page, force the next append to rotate into a fresh v2 page, - // so new (v2) records never land in a page whose header says v1 (which would make - // a later load discard their last_heard - including the role/protected we just set). - if (legacy) + // Rotate rather than append into an older-format page: a later load would + // normalise the new records to that page's format and drop metadata. + if (fmt != WarmFormat::Current) writeSlot = kRecordsPerPage; } } if (nCorrupt) LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt); if (migrated) - LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated); + LOG_INFO("WarmStore: migrated %u legacy record(s) to the current format", (unsigned)migrated); LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(), activePage, writeSlot); } @@ -537,10 +546,14 @@ void WarmNodeStore::load() LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName); return; } - // v1 (WRM1) is still accepted: same record size, but its last_heard was a plain - // timestamp. We keep identity + key and discard last_heard on load (see below). - const bool legacy = (h.magic == WARM_STORE_MAGIC_V1); - if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) { + // Older snapshots are still accepted: same record size, fewer metadata bits in + // last_heard. Both are normalised to the current format below. + const bool known = h.magic == WARM_STORE_MAGIC || h.magic == WARM_STORE_MAGIC_V2 || h.magic == WARM_STORE_MAGIC_V1; + const WarmFormat fmt = h.magic == WARM_STORE_MAGIC_V1 ? WarmFormat::V1 + : h.magic == WARM_STORE_MAGIC_V2 ? WarmFormat::V2 + : WarmFormat::Current; + const bool legacy = fmt != WarmFormat::Current; + if (!known || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) { f.close(); LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic, h.entrySize, h.count); @@ -560,19 +573,24 @@ void WarmNodeStore::load() memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry)); return; } - if (legacy) { - // Migrate v1 → v2: discard the old last_heard (its low bits would be misread as - // role/protected); keep num + public_key. Mark dirty so save() rewrites as v2. + // Normalise older records, then mark dirty so save() rewrites in the current format: + // v1's timestamp would be misread as role/protected, v2's bit 6 as an unverified signer. + if (fmt == WarmFormat::V1) { for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (entries[i].num) entries[i].last_heard = 0; dirty = true; + } else if (fmt == WarmFormat::V2) { + for (size_t i = 0; i < WARM_NODE_COUNT; i++) + if (entries[i].num) + entries[i].last_heard &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); + dirty = true; } } else { f.close(); } LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName, - legacy ? " (v1 migrated: discarded last_heard)" : ""); + legacy ? " (migrated from an older format)" : ""); } bool WarmNodeStore::save() diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 4cacbbc0c..96bfd3ec0 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -43,27 +43,33 @@ static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B - persi // // The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute // recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry -// the evicted node's device role + a protected category, at zero cost to record size -// (entry stays 40 B; no RAM/flash growth). The high bits remain a real unix-seconds -// timestamp quantised to (1 << WARM_META_BITS) seconds. +// the evicted node's device role, a protected category + a signer flag, at zero cost to +// record size (entry stays 40 B; no RAM/flash growth). The high bits remain a real +// unix-seconds timestamp quantised to (1 << WARM_META_BITS) seconds. // // Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before // 2106, and tombstones/erased flash are detected via num before last_heard is read. Only // the LOW bits are stolen - the high (era) bits are untouched, so the time range is intact. -static constexpr uint32_t WARM_META_BITS = 6; // role(4) + protected(2) -static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F → 64 s quantum -static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0 +static constexpr uint32_t WARM_META_BITS = 7; // role(4) + protected(2) + signer(1) +static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x7F → 128 s quantum +static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFF80 static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12) static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category static constexpr uint32_t WARM_PROT_MASK = 0x03u; +static constexpr uint32_t WARM_SIGNER_SHIFT = 6; // bit [6] we verified an XEdDSA signature from this node +static constexpr uint32_t WARM_SIGNER_MASK = 0x01u; + +// On-disk record format, from the page/file magic; older ones are normalised by load(). +enum class WarmFormat : uint8_t { Current, V2, V1 }; // Protected category cached alongside role so consumers needn't re-derive the mapping. enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 }; -inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot) +inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer) { return (lastHeard & WARM_TIME_MASK) | (static_cast(role) & WARM_ROLE_MASK) | - ((static_cast(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT); + ((static_cast(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT) | + ((signer ? WARM_SIGNER_MASK : 0u) << WARM_SIGNER_SHIFT); } inline uint32_t warmTimeOf(const WarmNodeEntry &e) { @@ -77,6 +83,10 @@ inline uint8_t warmProtOf(const WarmNodeEntry &e) { return static_cast((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK); } +inline bool warmSignerOf(const WarmNodeEntry &e) +{ + return ((e.last_heard >> WARM_SIGNER_SHIFT) & WARM_SIGNER_MASK) != 0; +} // Gated on NRF52840_XXAA: the ring sits at 0xEA000 // valid only on the 1 MB-flash nRF52840. @@ -99,9 +109,11 @@ class WarmNodeStore /// entries; otherwise the oldest (keyless-first) entry is replaced. /// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12) /// @param protectedCat WarmProtected category cached for the hop-trim path + /// @param signer true if we ever verified an XEdDSA signature from this node, so + /// re-admission restores the bit rather than relearning it /// @return true if the node was stored or updated bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0, - uint8_t protectedCat = 0); + uint8_t protectedCat = 0, bool signer = false); /// Look up the cached device role + protected category for a warm node. /// @return false if the node is not in the warm tier. @@ -167,7 +179,7 @@ class WarmNodeStore void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */); void ringRotate(); // reclaim oldest page, compacting stranded live entries void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++) - bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const; + bool ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt = nullptr) const; #endif bool save(); diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index 46981498f..bf6be0b9c 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -1,8 +1,8 @@ #include "mesh/eth/ethClient.h" #include "NodeDB.h" -#include "RTC.h" #include "concurrency/Periodic.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "mesh/api/ethServerAPI.h" #include "target_specific.h" diff --git a/src/mesh/generated/meshtastic/interdevice.pb.cpp b/src/mesh/generated/meshtastic/interdevice.pb.cpp index e3913f78c..bc795b2a7 100644 --- a/src/mesh/generated/meshtastic/interdevice.pb.cpp +++ b/src/mesh/generated/meshtastic/interdevice.pb.cpp @@ -6,10 +6,34 @@ #error Regenerate this file with the current version of nanopb generator. #endif -PB_BIND(meshtastic_SensorData, meshtastic_SensorData, AUTO) +PB_BIND(meshtastic_FileTransfer, meshtastic_FileTransfer, 4) + + +PB_BIND(meshtastic_DirectoryListing, meshtastic_DirectoryListing, 4) + + +PB_BIND(meshtastic_I2CTransaction, meshtastic_I2CTransaction, 2) + + +PB_BIND(meshtastic_SdCardInfo, meshtastic_SdCardInfo, AUTO) + + +PB_BIND(meshtastic_I2CResult, meshtastic_I2CResult, 2) + + +PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 4) + + + + + + + + + + -PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 2) diff --git a/src/mesh/generated/meshtastic/interdevice.pb.h b/src/mesh/generated/meshtastic/interdevice.pb.h index c381438eb..92a1670ee 100644 --- a/src/mesh/generated/meshtastic/interdevice.pb.h +++ b/src/mesh/generated/meshtastic/interdevice.pb.h @@ -10,38 +10,194 @@ #endif /* Enum definitions */ -typedef enum _meshtastic_MessageType { - meshtastic_MessageType_ACK = 0, - meshtastic_MessageType_COLLECT_INTERVAL = 160, /* in ms */ - meshtastic_MessageType_BEEP_ON = 161, /* duration ms */ - meshtastic_MessageType_BEEP_OFF = 162, /* cancel prematurely */ - meshtastic_MessageType_SHUTDOWN = 163, - meshtastic_MessageType_POWER_ON = 164, - meshtastic_MessageType_SCD41_TEMP = 176, - meshtastic_MessageType_SCD41_HUMIDITY = 177, - meshtastic_MessageType_SCD41_CO2 = 178, - meshtastic_MessageType_AHT20_TEMP = 179, - meshtastic_MessageType_AHT20_HUMIDITY = 180, - meshtastic_MessageType_TVOC_INDEX = 181 -} meshtastic_MessageType; +/* Version of the interdevice protocol spoken on the link. Both sides send + theirs in the ping/pong handshake; a peer reporting a different one runs + firmware that does not match and is not talked to. + + On a change that breaks the other side (renumbered fields, changed + semantics, removed messages), raise the value of CURRENT. Do not add + another entry: this enum carries a single constant, not a history. */ +typedef enum _meshtastic_InterdeviceVersion { + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_UNSPECIFIED = 0, + /* Never use 1: ping/pong were bools before the handshake existed, and a + bool true is the same varint on the wire as the number 1, so firmware + predating the handshake would pass it. */ + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT = 2 +} meshtastic_InterdeviceVersion; + +/* Defines the supported file operations */ +typedef enum _meshtastic_FileOperation { + meshtastic_FileOperation_GET = 0, + meshtastic_FileOperation_POST = 1, + meshtastic_FileOperation_PUT = 2, + meshtastic_FileOperation_DELETE = 3 +} meshtastic_FileOperation; + +/* Outcome of a file or directory operation. The requester must be able to + tell a transient condition from a definitive one: BUSY is worth another + try, NOT_FOUND is not. */ +typedef enum _meshtastic_FileStatus { + meshtastic_FileStatus_FILE_UNSPECIFIED = 0, + meshtastic_FileStatus_FILE_OK = 1, + /* Retry later: the co-processor is doing card maintenance (mount, + free space scan) and cannot serve the request right now */ + meshtastic_FileStatus_FILE_BUSY = 2, + meshtastic_FileStatus_FILE_NO_CARD = 3, + meshtastic_FileStatus_FILE_NOT_FOUND = 4, + /* PUT only: offset did not match the current end of the file. file_size + carries the size the file actually has, so the writer can resync (or + recognize its own chunk as already written after a lost response). */ + meshtastic_FileStatus_FILE_OFFSET_CONFLICT = 5, + meshtastic_FileStatus_FILE_IO_ERROR = 6, + meshtastic_FileStatus_FILE_NOT_A_FILE = 7 /* path is a directory (GET) or not one (listing) */ +} meshtastic_FileStatus; + +/* What to do with the SD card of the co-processor */ +typedef enum _meshtastic_SdCommand { + meshtastic_SdCommand_SD_COMMAND_UNSPECIFIED = 0, + meshtastic_SdCommand_SD_MOUNT = 1, /* mount a card that is in the slot, also after an eject */ + meshtastic_SdCommand_SD_EJECT = 2, /* flush and release the card so it can be pulled safely */ + meshtastic_SdCommand_SD_FORMAT = 3 /* wipe the card and put a fresh FAT on it, then mount it */ +} meshtastic_SdCommand; + +typedef enum _meshtastic_SdCardInfo_CardType { + meshtastic_SdCardInfo_CardType_NONE = 0, + meshtastic_SdCardInfo_CardType_MMC = 1, + meshtastic_SdCardInfo_CardType_SD = 2, + meshtastic_SdCardInfo_CardType_SDHC = 3, + meshtastic_SdCardInfo_CardType_SDXC = 4, + meshtastic_SdCardInfo_CardType_UNKNOWN_CARD = 5 +} meshtastic_SdCardInfo_CardType; + +typedef enum _meshtastic_SdCardInfo_FatType { + meshtastic_SdCardInfo_FatType_UNKNOWN_FAT = 0, + meshtastic_SdCardInfo_FatType_FAT16 = 1, + meshtastic_SdCardInfo_FatType_FAT32 = 2, + meshtastic_SdCardInfo_FatType_EXFAT = 3 +} meshtastic_SdCardInfo_FatType; + +typedef enum _meshtastic_I2CResult_Status { + /* Never sent: an all-defaults (e.g. accidentally empty) message must + not decode as a successful transaction */ + meshtastic_I2CResult_Status_UNSPECIFIED = 0, + meshtastic_I2CResult_Status_OK = 1, + meshtastic_I2CResult_Status_NACK_ADDRESS = 2, + meshtastic_I2CResult_Status_NACK_DATA = 3, + meshtastic_I2CResult_Status_ERROR = 4 +} meshtastic_I2CResult_Status; /* Struct definitions */ -typedef struct _meshtastic_SensorData { - /* The message type */ - meshtastic_MessageType type; - pb_size_t which_data; - union { - float float_value; - uint32_t uint32_value; - } data; -} meshtastic_SensorData; +typedef PB_BYTES_ARRAY_T(4096) meshtastic_FileTransfer_filedata_t; +/* Message for file operations */ +typedef struct _meshtastic_FileTransfer { + meshtastic_FileOperation operation; /* File operation (GET, POST, PUT, DELETE) */ + char filepath[256]; /* Path of the file on the SD card */ + meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ + meshtastic_FileStatus status; /* Response: outcome of the operation */ + char message[255]; /* Response: human readable detail, may be empty */ + uint64_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ + /* GET request: number of bytes to read, 0 = max chunk size. A response + carries at most the filedata max_size (see interdevice.options) per + chunk; larger requests are truncated, visible in the filedata length. */ + uint32_t length; + uint64_t file_size; /* GET response: total size of the file */ +} meshtastic_FileTransfer; +/* Message for structured directory listing */ +typedef struct _meshtastic_DirectoryListing { + char directory[256]; /* Path of the directory */ + /* One page of entry names, full FAT LFN length. Subdirectories carry a + trailing slash. Note that a name whose directory prefix pushes the + combined path past the FileTransfer.filepath limit cannot round-trip. + Page size is the max_count in interdevice.options; page through with + offset and total_count. */ + pb_size_t filenames_count; + char filenames[16][256]; + meshtastic_FileStatus status; /* Response: outcome of the operation */ + char message[255]; /* Response: human readable detail, may be empty */ + uint32_t offset; /* Request: skip this many entries (paging) */ + uint32_t total_count; /* Response: total number of entries in the directory */ +} meshtastic_DirectoryListing; + +typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CTransaction_write_data_t; +/* A single I2C transaction: an optional write followed by an optional + read with repeated start, matching the TwoWire usage of sensor drivers + (beginTransmission/write.../endTransmission(false)/requestFrom) */ +typedef struct _meshtastic_I2CTransaction { + uint32_t address; /* 7-bit device address */ + meshtastic_I2CTransaction_write_data_t write_data; /* Bytes to write, may be empty */ + /* Number of bytes to read after the write, 0 = write-only. Bounded by + the read_data max_size of I2CResult (see interdevice.options); larger + requests are truncated, visible in the returned byte count. */ + uint32_t read_len; +} meshtastic_I2CTransaction; + +/* SD card statistics */ +typedef struct _meshtastic_SdCardInfo { + /* Card initialized and usable. False while `busy` is set does not mean + there is no card: the co-processor does not know yet. */ + bool present; + meshtastic_SdCardInfo_CardType card_type; + meshtastic_SdCardInfo_FatType fat_type; + uint64_t card_size; /* Filesystem size in bytes */ + uint64_t used_bytes; /* Used bytes (may be expensive to compute on FAT32) */ + uint64_t free_bytes; /* Free bytes */ + /* used_bytes/free_bytes are only meaningful when true: the scan behind + them runs in the background after mount and can take a while, and a + full card is otherwise indistinguishable from a scan in progress */ + bool stats_valid; + /* The co-processor is mounting a card right now, so whether one is + present is not decided yet. Ask again rather than concluding the slot + is empty. */ + bool busy; + /* A card answers in the slot but carries no filesystem that could be + mounted (present is false then). Formatting it makes it usable. */ + bool unformatted; +} meshtastic_SdCardInfo; + +typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CResult_read_data_t; +/* Result of an I2CTransaction */ +typedef struct _meshtastic_I2CResult { + meshtastic_I2CResult_Status status; + meshtastic_I2CResult_read_data_t read_data; /* Data read from the device, empty for write-only transactions */ +} meshtastic_I2CResult; + +typedef PB_BYTES_ARRAY_T(128) meshtastic_InterdeviceMessage_i2c_scan_result_t; +/* Main message for interdevice communication */ typedef struct _meshtastic_InterdeviceMessage { pb_size_t which_data; union { char nmea[1024]; - meshtastic_SensorData sensor; + uint16_t beep; + meshtastic_I2CTransaction i2c_transaction; + meshtastic_I2CResult i2c_result; + bool i2c_scan; /* Request: scan the secondary I2C bus */ + meshtastic_InterdeviceMessage_i2c_scan_result_t i2c_scan_result; /* Response: 7-bit addresses of discovered devices */ + meshtastic_FileTransfer file_transfer; + meshtastic_DirectoryListing directory_listing; + bool get_sd_info; /* Request: SD card statistics */ + meshtastic_SdCardInfo sd_info; /* Response */ + /* Link liveness probe and version handshake. The receiver answers ping + with pong, echoing the id. Touches no peripherals, so it works with + nothing attached. Both carry the version the sender speaks; a peer + that answers with a different one speaks another protocol and must + not be used. */ + meshtastic_InterdeviceVersion ping; + meshtastic_InterdeviceVersion pong; + /* Response: the request could not be decoded or is of an unhandled + type, so the requester fails fast instead of burning its timeout. + Echoes the id when known, 0 when the frame was undecodable. Never + sent in reaction to a nack. */ + bool nack; + /* Request: mount the card, or release it so it can be pulled safely. The + co-processor answers with sd_info. Without an eject the card is mounted + on its own and kept mounted; after one it stays released until a mount + is asked for. */ + meshtastic_SdCommand sd_command; } data; + /* Correlates a response with its request: responses echo the id of the + request they answer. 0 for unsolicited messages (e.g. the nmea stream). */ + uint32_t id; } meshtastic_InterdeviceMessage; @@ -50,53 +206,205 @@ extern "C" { #endif /* Helper constants for enums */ -#define _meshtastic_MessageType_MIN meshtastic_MessageType_ACK -#define _meshtastic_MessageType_MAX meshtastic_MessageType_TVOC_INDEX -#define _meshtastic_MessageType_ARRAYSIZE ((meshtastic_MessageType)(meshtastic_MessageType_TVOC_INDEX+1)) +#define _meshtastic_InterdeviceVersion_MIN meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_UNSPECIFIED +#define _meshtastic_InterdeviceVersion_MAX meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT +#define _meshtastic_InterdeviceVersion_ARRAYSIZE ((meshtastic_InterdeviceVersion)(meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT+1)) -#define meshtastic_SensorData_type_ENUMTYPE meshtastic_MessageType +#define _meshtastic_FileOperation_MIN meshtastic_FileOperation_GET +#define _meshtastic_FileOperation_MAX meshtastic_FileOperation_DELETE +#define _meshtastic_FileOperation_ARRAYSIZE ((meshtastic_FileOperation)(meshtastic_FileOperation_DELETE+1)) +#define _meshtastic_FileStatus_MIN meshtastic_FileStatus_FILE_UNSPECIFIED +#define _meshtastic_FileStatus_MAX meshtastic_FileStatus_FILE_NOT_A_FILE +#define _meshtastic_FileStatus_ARRAYSIZE ((meshtastic_FileStatus)(meshtastic_FileStatus_FILE_NOT_A_FILE+1)) + +#define _meshtastic_SdCommand_MIN meshtastic_SdCommand_SD_COMMAND_UNSPECIFIED +#define _meshtastic_SdCommand_MAX meshtastic_SdCommand_SD_FORMAT +#define _meshtastic_SdCommand_ARRAYSIZE ((meshtastic_SdCommand)(meshtastic_SdCommand_SD_FORMAT+1)) + +#define _meshtastic_SdCardInfo_CardType_MIN meshtastic_SdCardInfo_CardType_NONE +#define _meshtastic_SdCardInfo_CardType_MAX meshtastic_SdCardInfo_CardType_UNKNOWN_CARD +#define _meshtastic_SdCardInfo_CardType_ARRAYSIZE ((meshtastic_SdCardInfo_CardType)(meshtastic_SdCardInfo_CardType_UNKNOWN_CARD+1)) + +#define _meshtastic_SdCardInfo_FatType_MIN meshtastic_SdCardInfo_FatType_UNKNOWN_FAT +#define _meshtastic_SdCardInfo_FatType_MAX meshtastic_SdCardInfo_FatType_EXFAT +#define _meshtastic_SdCardInfo_FatType_ARRAYSIZE ((meshtastic_SdCardInfo_FatType)(meshtastic_SdCardInfo_FatType_EXFAT+1)) + +#define _meshtastic_I2CResult_Status_MIN meshtastic_I2CResult_Status_UNSPECIFIED +#define _meshtastic_I2CResult_Status_MAX meshtastic_I2CResult_Status_ERROR +#define _meshtastic_I2CResult_Status_ARRAYSIZE ((meshtastic_I2CResult_Status)(meshtastic_I2CResult_Status_ERROR+1)) + +#define meshtastic_FileTransfer_operation_ENUMTYPE meshtastic_FileOperation +#define meshtastic_FileTransfer_status_ENUMTYPE meshtastic_FileStatus + +#define meshtastic_DirectoryListing_status_ENUMTYPE meshtastic_FileStatus + + +#define meshtastic_SdCardInfo_card_type_ENUMTYPE meshtastic_SdCardInfo_CardType +#define meshtastic_SdCardInfo_fat_type_ENUMTYPE meshtastic_SdCardInfo_FatType + +#define meshtastic_I2CResult_status_ENUMTYPE meshtastic_I2CResult_Status + +#define meshtastic_InterdeviceMessage_data_ping_ENUMTYPE meshtastic_InterdeviceVersion +#define meshtastic_InterdeviceMessage_data_pong_ENUMTYPE meshtastic_InterdeviceVersion +#define meshtastic_InterdeviceMessage_data_sd_command_ENUMTYPE meshtastic_SdCommand /* Initializer values for message structs */ -#define meshtastic_SensorData_init_default {_meshtastic_MessageType_MIN, 0, {0}} -#define meshtastic_InterdeviceMessage_init_default {0, {""}} -#define meshtastic_SensorData_init_zero {_meshtastic_MessageType_MIN, 0, {0}} -#define meshtastic_InterdeviceMessage_init_zero {0, {""}} +#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} +#define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} +#define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0, 0} +#define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} +#define meshtastic_InterdeviceMessage_init_default {0, {""}, 0} +#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} +#define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} +#define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0, 0} +#define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} +#define meshtastic_InterdeviceMessage_init_zero {0, {""}, 0} /* Field tags (for use in manual encoding/decoding) */ -#define meshtastic_SensorData_type_tag 1 -#define meshtastic_SensorData_float_value_tag 2 -#define meshtastic_SensorData_uint32_value_tag 3 +#define meshtastic_FileTransfer_operation_tag 1 +#define meshtastic_FileTransfer_filepath_tag 2 +#define meshtastic_FileTransfer_filedata_tag 3 +#define meshtastic_FileTransfer_status_tag 4 +#define meshtastic_FileTransfer_message_tag 5 +#define meshtastic_FileTransfer_offset_tag 6 +#define meshtastic_FileTransfer_length_tag 7 +#define meshtastic_FileTransfer_file_size_tag 8 +#define meshtastic_DirectoryListing_directory_tag 1 +#define meshtastic_DirectoryListing_filenames_tag 2 +#define meshtastic_DirectoryListing_status_tag 3 +#define meshtastic_DirectoryListing_message_tag 4 +#define meshtastic_DirectoryListing_offset_tag 5 +#define meshtastic_DirectoryListing_total_count_tag 6 +#define meshtastic_I2CTransaction_address_tag 1 +#define meshtastic_I2CTransaction_write_data_tag 2 +#define meshtastic_I2CTransaction_read_len_tag 3 +#define meshtastic_SdCardInfo_present_tag 1 +#define meshtastic_SdCardInfo_card_type_tag 2 +#define meshtastic_SdCardInfo_fat_type_tag 3 +#define meshtastic_SdCardInfo_card_size_tag 4 +#define meshtastic_SdCardInfo_used_bytes_tag 5 +#define meshtastic_SdCardInfo_free_bytes_tag 6 +#define meshtastic_SdCardInfo_stats_valid_tag 7 +#define meshtastic_SdCardInfo_busy_tag 8 +#define meshtastic_SdCardInfo_unformatted_tag 9 +#define meshtastic_I2CResult_status_tag 1 +#define meshtastic_I2CResult_read_data_tag 2 #define meshtastic_InterdeviceMessage_nmea_tag 1 -#define meshtastic_InterdeviceMessage_sensor_tag 2 +#define meshtastic_InterdeviceMessage_beep_tag 2 +#define meshtastic_InterdeviceMessage_i2c_transaction_tag 3 +#define meshtastic_InterdeviceMessage_i2c_result_tag 4 +#define meshtastic_InterdeviceMessage_i2c_scan_tag 5 +#define meshtastic_InterdeviceMessage_i2c_scan_result_tag 6 +#define meshtastic_InterdeviceMessage_file_transfer_tag 7 +#define meshtastic_InterdeviceMessage_directory_listing_tag 8 +#define meshtastic_InterdeviceMessage_get_sd_info_tag 9 +#define meshtastic_InterdeviceMessage_sd_info_tag 10 +#define meshtastic_InterdeviceMessage_ping_tag 11 +#define meshtastic_InterdeviceMessage_pong_tag 12 +#define meshtastic_InterdeviceMessage_nack_tag 13 +#define meshtastic_InterdeviceMessage_sd_command_tag 14 +#define meshtastic_InterdeviceMessage_id_tag 15 /* Struct field encoding specification for nanopb */ -#define meshtastic_SensorData_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UENUM, type, 1) \ -X(a, STATIC, ONEOF, FLOAT, (data,float_value,data.float_value), 2) \ -X(a, STATIC, ONEOF, UINT32, (data,uint32_value,data.uint32_value), 3) -#define meshtastic_SensorData_CALLBACK NULL -#define meshtastic_SensorData_DEFAULT NULL +#define meshtastic_FileTransfer_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, operation, 1) \ +X(a, STATIC, SINGULAR, STRING, filepath, 2) \ +X(a, STATIC, SINGULAR, BYTES, filedata, 3) \ +X(a, STATIC, SINGULAR, UENUM, status, 4) \ +X(a, STATIC, SINGULAR, STRING, message, 5) \ +X(a, STATIC, SINGULAR, UINT64, offset, 6) \ +X(a, STATIC, SINGULAR, UINT32, length, 7) \ +X(a, STATIC, SINGULAR, UINT64, file_size, 8) +#define meshtastic_FileTransfer_CALLBACK NULL +#define meshtastic_FileTransfer_DEFAULT NULL + +#define meshtastic_DirectoryListing_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, directory, 1) \ +X(a, STATIC, REPEATED, STRING, filenames, 2) \ +X(a, STATIC, SINGULAR, UENUM, status, 3) \ +X(a, STATIC, SINGULAR, STRING, message, 4) \ +X(a, STATIC, SINGULAR, UINT32, offset, 5) \ +X(a, STATIC, SINGULAR, UINT32, total_count, 6) +#define meshtastic_DirectoryListing_CALLBACK NULL +#define meshtastic_DirectoryListing_DEFAULT NULL + +#define meshtastic_I2CTransaction_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, address, 1) \ +X(a, STATIC, SINGULAR, BYTES, write_data, 2) \ +X(a, STATIC, SINGULAR, UINT32, read_len, 3) +#define meshtastic_I2CTransaction_CALLBACK NULL +#define meshtastic_I2CTransaction_DEFAULT NULL + +#define meshtastic_SdCardInfo_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, BOOL, present, 1) \ +X(a, STATIC, SINGULAR, UENUM, card_type, 2) \ +X(a, STATIC, SINGULAR, UENUM, fat_type, 3) \ +X(a, STATIC, SINGULAR, UINT64, card_size, 4) \ +X(a, STATIC, SINGULAR, UINT64, used_bytes, 5) \ +X(a, STATIC, SINGULAR, UINT64, free_bytes, 6) \ +X(a, STATIC, SINGULAR, BOOL, stats_valid, 7) \ +X(a, STATIC, SINGULAR, BOOL, busy, 8) \ +X(a, STATIC, SINGULAR, BOOL, unformatted, 9) +#define meshtastic_SdCardInfo_CALLBACK NULL +#define meshtastic_SdCardInfo_DEFAULT NULL + +#define meshtastic_I2CResult_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, status, 1) \ +X(a, STATIC, SINGULAR, BYTES, read_data, 2) +#define meshtastic_I2CResult_CALLBACK NULL +#define meshtastic_I2CResult_DEFAULT NULL #define meshtastic_InterdeviceMessage_FIELDLIST(X, a) \ X(a, STATIC, ONEOF, STRING, (data,nmea,data.nmea), 1) \ -X(a, STATIC, ONEOF, MESSAGE, (data,sensor,data.sensor), 2) +X(a, STATIC, ONEOF, UINT32, (data,beep,data.beep), 2) \ +X(a, STATIC, ONEOF, MESSAGE, (data,i2c_transaction,data.i2c_transaction), 3) \ +X(a, STATIC, ONEOF, MESSAGE, (data,i2c_result,data.i2c_result), 4) \ +X(a, STATIC, ONEOF, BOOL, (data,i2c_scan,data.i2c_scan), 5) \ +X(a, STATIC, ONEOF, BYTES, (data,i2c_scan_result,data.i2c_scan_result), 6) \ +X(a, STATIC, ONEOF, MESSAGE, (data,file_transfer,data.file_transfer), 7) \ +X(a, STATIC, ONEOF, MESSAGE, (data,directory_listing,data.directory_listing), 8) \ +X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ +X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) \ +X(a, STATIC, ONEOF, UENUM, (data,ping,data.ping), 11) \ +X(a, STATIC, ONEOF, UENUM, (data,pong,data.pong), 12) \ +X(a, STATIC, ONEOF, BOOL, (data,nack,data.nack), 13) \ +X(a, STATIC, ONEOF, UENUM, (data,sd_command,data.sd_command), 14) \ +X(a, STATIC, SINGULAR, UINT32, id, 15) #define meshtastic_InterdeviceMessage_CALLBACK NULL #define meshtastic_InterdeviceMessage_DEFAULT NULL -#define meshtastic_InterdeviceMessage_data_sensor_MSGTYPE meshtastic_SensorData +#define meshtastic_InterdeviceMessage_data_i2c_transaction_MSGTYPE meshtastic_I2CTransaction +#define meshtastic_InterdeviceMessage_data_i2c_result_MSGTYPE meshtastic_I2CResult +#define meshtastic_InterdeviceMessage_data_file_transfer_MSGTYPE meshtastic_FileTransfer +#define meshtastic_InterdeviceMessage_data_directory_listing_MSGTYPE meshtastic_DirectoryListing +#define meshtastic_InterdeviceMessage_data_sd_info_MSGTYPE meshtastic_SdCardInfo -extern const pb_msgdesc_t meshtastic_SensorData_msg; +extern const pb_msgdesc_t meshtastic_FileTransfer_msg; +extern const pb_msgdesc_t meshtastic_DirectoryListing_msg; +extern const pb_msgdesc_t meshtastic_I2CTransaction_msg; +extern const pb_msgdesc_t meshtastic_SdCardInfo_msg; +extern const pb_msgdesc_t meshtastic_I2CResult_msg; extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ -#define meshtastic_SensorData_fields &meshtastic_SensorData_msg +#define meshtastic_FileTransfer_fields &meshtastic_FileTransfer_msg +#define meshtastic_DirectoryListing_fields &meshtastic_DirectoryListing_msg +#define meshtastic_I2CTransaction_fields &meshtastic_I2CTransaction_msg +#define meshtastic_SdCardInfo_fields &meshtastic_SdCardInfo_msg +#define meshtastic_I2CResult_fields &meshtastic_I2CResult_msg #define meshtastic_InterdeviceMessage_fields &meshtastic_InterdeviceMessage_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_INTERDEVICE_PB_H_MAX_SIZE meshtastic_InterdeviceMessage_size -#define meshtastic_InterdeviceMessage_size 1026 -#define meshtastic_SensorData_size 9 +#define meshtastic_DirectoryListing_size 4657 +#define meshtastic_FileTransfer_size 4646 +#define meshtastic_I2CResult_size 261 +#define meshtastic_I2CTransaction_size 271 +#define meshtastic_InterdeviceMessage_size 4666 +#define meshtastic_SdCardInfo_size 45 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 965a93157..36f930561 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -121,7 +121,9 @@ typedef enum _meshtastic_TelemetrySensorType { /* ICM-42607-P 6‑Axis IMU */ meshtastic_TelemetrySensorType_ICM42607P = 53, /* SPA06 pressure and temperature */ - meshtastic_TelemetrySensorType_SPA06 = 54 + meshtastic_TelemetrySensorType_SPA06 = 54, + /* HM330X PM SENSOR */ + meshtastic_TelemetrySensorType_HM330X = 55 } meshtastic_TelemetrySensorType; /* Struct definitions */ @@ -502,8 +504,8 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SPA06 -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SPA06+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_HM330X +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_HM330X+1)) diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index fddc118a7..5e42aa389 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -96,14 +96,6 @@ static void taskCreateCert(void *parameter) { prefs.begin("MeshtasticHTTPS", false); -#if 0 - // Delete the saved certs (used in debugging) - LOG_DEBUG("Delete any saved SSL keys"); - // prefs.clear(); - prefs.remove("PK"); - prefs.remove("cert"); -#endif - LOG_INFO("Checking if we have a saved SSL Certificate"); size_t pkLen = prefs.getBytesLength("PK"); diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 49cd3ddb2..b4c99cd27 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -231,8 +231,6 @@ int callback_static_file(const struct _u_request *request, struct _u_response *r } } -static void handleWebResponse() {} - /* * Adapt the radioapi to the Webservice handleAPIv1ToRadio * Trigger : WebGui(SAVE)->WebServcice->phoneApi diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 3d0f4baaa..d84bbfeb7 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -1,8 +1,8 @@ #include "configuration.h" #if HAS_WIFI #include "NodeDB.h" -#include "RTC.h" #include "concurrency/Periodic.h" +#include "gps/RTC.h" #include "mesh/wifi/WiFiAPClient.h" #include "main.h" diff --git a/src/meshUtils.cpp b/src/meshUtils.cpp index 1fd21a244..834cc7276 100644 --- a/src/meshUtils.cpp +++ b/src/meshUtils.cpp @@ -1,4 +1,5 @@ #include "meshUtils.h" +#include "target_specific.h" #include /* @@ -79,6 +80,20 @@ bool memfll(const uint8_t *mem, uint8_t find, size_t numbytes) return true; } +bool getMacAddrDeviceId(uint8_t *deviceId) +{ + // Zero-initialized: getMacAddr() may return without writing (e.g. Portduino with no MAC + // source), and we want that no-write case to hit the all-zeros guard below, not read garbage. + uint8_t mac[6] = {0}; + getMacAddr(mac); + if (memfll(mac, 0, sizeof(mac))) { + LOG_WARN("MAC is all zeros, leaving device_id unset"); + return false; + } + memcpy(deviceId, mac, sizeof(mac)); + return true; +} + bool isOneOf(int item, int count, ...) { va_list args; diff --git a/src/meshUtils.h b/src/meshUtils.h index 0ce01c522..9a80ac51f 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -49,6 +49,10 @@ void printBytes(const char *label, const uint8_t *p, size_t numbytes); // is the memory region filled with a single character? bool memfll(const uint8_t *mem, uint8_t find, size_t numbytes); +// getDeviceId() fallback (see target_specific.h): copies the 6-byte factory MAC, or returns false +// on an all-zero MAC so two blank devices don't collide on an all-zero id. +bool getMacAddrDeviceId(uint8_t *deviceId); + bool isOneOf(int item, int count, ...); const std::string vformat(const char *const zcFormat, ...); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 278fe6179..e7990a5a5 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,15 +1,17 @@ #include "AdminModule.h" #include "Channels.h" #include "DisplayFormatters.h" +#include "HardwareRNG.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" #include "PowerFSM.h" -#include "RTC.h" #include "SPILock.h" +#include "gps/RTC.h" #include "input/InputBroker.h" #include "meshUtils.h" #include +#include #include // for better whitespace handling #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI #include "MeshtasticOTA.h" @@ -48,6 +50,9 @@ #include "GPS.h" #endif +#include // CryptRNG, the seeded CSPRNG used as fallback for the session passkey +#include // rollover-safe elapsed-time checks for the session passkey + #if MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif @@ -125,9 +130,16 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } #endif meshtastic_Channel *ch = &channels.getByIndex(mp.channel); - // Could tighten this up further by tracking the last public_key we went an AdminMessage request to - // and only allowing responses from that remote. if (messageIsResponse(r)) { + // Only accept a response from a remote we sent the matching request to. from == 0 is a + // local client, which PhoneAPI has already gated. + const pb_size_t moduleConfigTag = r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag + ? r->get_module_config_response.which_payload_variant + : 0; + if (mp.from != 0 && !responseIsSolicited(mp, r->which_payload_variant, moduleConfigTag)) { + LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from); + return handled; + } LOG_DEBUG("Allow admin response message"); } else if (mp.from == 0) { // Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the @@ -472,8 +484,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } case meshtastic_AdminMessage_get_module_config_response_tag: { LOG_INFO("Client received a get_module_config response"); - if (fromOthers && r->get_module_config_response.which_payload_variant == - meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) { + // which_payload_variant is the ModuleConfig oneof tag, so compare against that tag, not the + // AdminMessage ModuleConfigType enum (whose REMOTEHARDWARE value is a different number). + if (fromOthers && r->get_module_config_response.which_payload_variant == meshtastic_ModuleConfig_remote_hardware_tag) { handleGetModuleConfigResponse(mp, r); } break; @@ -773,6 +786,27 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) } } +#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ + !MESHTASTIC_EXCLUDE_ACCELEROMETER +// Shared by double-tap-as-button (device) and wake-on-motion (display). Start on either flag's +// off->on edge; stop on the on->off edge once neither needs it (disable() clears `enabled` so a +// later re-enable restarts). Skip the stop if the sensor also drives the compass, else the heading +// freezes until reboot. wasOn/nowOn = old/new of the changed flag; otherFeatureOn = the other flag. +static void reconcileAccelerometerThread(bool wasOn, bool nowOn, bool otherFeatureOn) +{ + if (!accelerometerThread) // null unless a sensor was detected at boot + return; + + if (!wasOn && nowOn && accelerometerThread->enabled == false) { + accelerometerThread->enabled = true; + accelerometerThread->start(); + } else if (wasOn && !nowOn && !otherFeatureOn && accelerometerThread->enabled == true && + !accelerometerThread->providesHeading()) { + accelerometerThread->disable(); + } +} +#endif + void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) { auto changes = SEGMENT_CONFIG; @@ -788,12 +822,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.has_device = true; #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ !MESHTASTIC_EXCLUDE_ACCELEROMETER - if (config.device.double_tap_as_button_press == false && c.payload_variant.device.double_tap_as_button_press == true && - accelerometerThread->enabled == false) { - config.device.double_tap_as_button_press = c.payload_variant.device.double_tap_as_button_press; - accelerometerThread->enabled = true; - accelerometerThread->start(); - } + reconcileAccelerometerThread(config.device.double_tap_as_button_press, + c.payload_variant.device.double_tap_as_button_press, config.display.wake_on_tap_or_motion); #endif if (config.device.button_gpio == c.payload_variant.device.button_gpio && config.device.buzzer_gpio == c.payload_variant.device.buzzer_gpio && @@ -892,12 +922,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ !MESHTASTIC_EXCLUDE_ACCELEROMETER - if (config.display.wake_on_tap_or_motion == false && c.payload_variant.display.wake_on_tap_or_motion == true && - accelerometerThread->enabled == false) { - config.display.wake_on_tap_or_motion = c.payload_variant.display.wake_on_tap_or_motion; - accelerometerThread->enabled = true; - accelerometerThread->start(); - } + reconcileAccelerometerThread(config.display.wake_on_tap_or_motion, c.payload_variant.display.wake_on_tap_or_motion, + config.device.double_tap_as_button_press); #endif config.display = c.payload_variant.display; break; @@ -1408,6 +1434,15 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32 LOG_INFO("Get config: Security"); res.get_config_response.which_payload_variant = meshtastic_Config_security_tag; res.get_config_response.payload_variant.security = config.security; + // The device identity private key is backup material for the local owner only. A local + // admin client sets from == 0 (BLE/USB/TCP); never return the key to a remote requester, + // even an authorized one, since it would travel over the air. public_key/admin_key are + // public and stay put. + if (req.from != 0) { + auto &sec = res.get_config_response.payload_variant.security; + memset(sec.private_key.bytes, 0, sizeof(sec.private_key.bytes)); + sec.private_key.size = 0; + } break; case meshtastic_AdminMessage_ConfigType_SESSIONKEY_CONFIG: LOG_INFO("Get config: Sessionkey"); @@ -1774,11 +1809,15 @@ AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_AP void AdminModule::setPassKey(meshtastic_AdminMessage *res) { - if (session_time == 0 || millis() / 1000 > session_time + 150) { - for (int i = 0; i < 8; i++) { - session_passkey[i] = random(); - } - session_time = millis() / 1000; + // Regenerate once there is no session yet or the current key is older than 150s. session_time + // holds millis(); the Throttle check is rollover-safe, unlike the previous seconds comparison. + if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) { + // Session passkey authenticates admin replies, so it must be unpredictable: prefer the + // hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists. + if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey))) + CryptRNG.rand(session_passkey, sizeof(session_passkey)); + session_time = millis(); + sessionPasskeyValid = true; } memcpy(res->session_passkey.bytes, session_passkey, 8); res->session_passkey.size = 8; @@ -1791,7 +1830,8 @@ bool AdminModule::checkPassKey(meshtastic_AdminMessage *res) { // check that the key in the packet is still valid printBytes("Incoming session key: ", res->session_passkey.bytes, 8); printBytes("Expected session key: ", session_passkey, 8); - return (session_time + 300 > millis() / 1000 && res->session_passkey.size == 8 && + // Key is valid for 300s from issue; sessionPasskeyValid guards an unissued session. + return (sessionPasskeyValid && Throttle::isWithinTimespanMs(session_time, 300 * 1000UL) && res->session_passkey.size == 8 && memcmp(res->session_passkey.bytes, session_passkey, 8) == 0); } @@ -1812,6 +1852,107 @@ bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r) return false; } +// The response variant that answers a getter request, or 0 if the request has none. +static pb_size_t adminResponseForRequest(pb_size_t requestVariant) +{ + switch (requestVariant) { + case meshtastic_AdminMessage_get_channel_request_tag: + return meshtastic_AdminMessage_get_channel_response_tag; + case meshtastic_AdminMessage_get_owner_request_tag: + return meshtastic_AdminMessage_get_owner_response_tag; + case meshtastic_AdminMessage_get_config_request_tag: + return meshtastic_AdminMessage_get_config_response_tag; + case meshtastic_AdminMessage_get_module_config_request_tag: + return meshtastic_AdminMessage_get_module_config_response_tag; + case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag: + return meshtastic_AdminMessage_get_canned_message_module_messages_response_tag; + case meshtastic_AdminMessage_get_device_metadata_request_tag: + return meshtastic_AdminMessage_get_device_metadata_response_tag; + case meshtastic_AdminMessage_get_ringtone_request_tag: + return meshtastic_AdminMessage_get_ringtone_response_tag; + case meshtastic_AdminMessage_get_device_connection_status_request_tag: + return meshtastic_AdminMessage_get_device_connection_status_response_tag; + case meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag: + return meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag; + case meshtastic_AdminMessage_get_ui_config_request_tag: + return meshtastic_AdminMessage_get_ui_config_response_tag; + default: + return 0; + } +} + +void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p) +{ + if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP) + return; + // Local admin is answered in-process, and a broadcast is never a request to one remote. + if (p.to == 0 || isBroadcast(p.to) || p.to == nodeDB->getNodeNum()) + return; + + meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero; + if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) + return; + const pb_size_t responseVariant = adminResponseForRequest(admin.which_payload_variant); + if (!responseVariant) + return; // not a getter whose response we can pair + + const bool keyValid = p.pki_encrypted && p.public_key.size == 32; + + // One entry per request (a client sends N indexed get_channel requests, each answered once, so + // entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time. + OutstandingAdminRequest *slot = nullptr; + for (auto &o : outstandingAdminRequests) + if (o.to == 0) { + slot = &o; + break; + } + if (!slot) { + const uint32_t now = millis(); + slot = &outstandingAdminRequests[0]; + for (auto &o : outstandingAdminRequests) + if ((uint32_t)(now - o.sentAtMs) > (uint32_t)(now - slot->sentAtMs)) + slot = &o; + } + + slot->to = p.to; + slot->sentAtMs = millis(); + slot->expectedResponse = responseVariant; + slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag + ? (uint8_t)admin.get_module_config_request + : 0; + slot->keyValid = keyValid; + if (keyValid) + memcpy(slot->key, p.public_key.bytes, 32); + else + memset(slot->key, 0, 32); + LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to); +} + +bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag) +{ + // Scan every entry: several requests for the same variant may differ only in pinning, and an + // unpinned one still authorizes the response even if a pinned one does not. + for (auto &o : outstandingAdminRequests) { + if (o.to != mp.from || o.expectedResponse != responseVariant) + continue; + if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) { + o.to = 0; // lapsed; free the slot and keep looking for another live match + continue; + } + // A request pinned to a PKC key must be answered over PKC by that same key. + if (o.keyValid && (!mp.pki_encrypted || mp.public_key.size != 32 || memcmp(mp.public_key.bytes, o.key, 32) != 0)) + continue; + // remote_hardware is the only module config that mutates state (the pin table), so require + // it to answer a request for that exact subtype, not just any get_module_config_request. + if (moduleConfigTag == meshtastic_ModuleConfig_remote_hardware_tag && + o.moduleConfigType != meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) + continue; + o.to = 0; // consume: one request authorizes one response, no replay within the window + return true; + } + return false; +} + bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r) { if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_request_tag || diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 15923988b..5c1a6ef58 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -41,7 +41,8 @@ class AdminModule : public ProtobufModule, public Obser bool hasOpenEditTransaction = false; uint8_t session_passkey[8] = {0}; - uint session_time = 0; + uint32_t session_time = 0; // millis() when the current session passkey was issued + bool sessionPasskeyValid = false; // separate flag: millis() 0 at boot is a valid issue time void saveChanges(int saveWhat, bool shouldReboot = true); @@ -77,7 +78,29 @@ class AdminModule : public ProtobufModule, public Obser public: void handleSetHamMode(const meshtastic_HamParameters &req); + /// Note an admin request leaving this node for a remote, so that remote's response is + /// accepted. Called from the client-to-mesh path (MeshService::handleToRadio). + void noteOutgoingAdminRequest(const meshtastic_MeshPacket &p); + private: + // An admin response has no session passkey and its sender need not hold an admin key, so a + // request we sent is the only thing vouching for it. Track each request independently (its own + // expiry and pinned key) so a later one can't extend or relax an earlier one. + static constexpr size_t kOutstandingAdminRequests = 8; + static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey + struct OutstandingAdminRequest { + NodeNum to; // 0 = free slot + uint32_t sentAtMs; // millis() when this request went out + pb_size_t expectedResponse; // the one response variant this request authorizes + uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked + uint8_t key[32]; // pinned destination key when the request went out over PKC + bool keyValid; + }; + OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {}; + + /// Whether a response (variant responseVariant, module-config subtype moduleConfigTag or 0) + /// from mp.from answers a request we sent; consumes the matched request so it can't be replayed. + bool responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag); void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg); void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent); void reboot(int32_t seconds); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 7d3116127..771066054 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -289,10 +289,6 @@ void CannedMessageModule::updateDestinationSelectionList() } } - meshtastic_MeshPacket *p = allocDataPacket(); - p->pki_encrypted = true; - p->channel = 0; - // Populate active channels std::vector seenChannels; seenChannels.reserve(channels.getNumChannels()); diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index a6757e04b..276c382a1 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -16,10 +16,10 @@ #include "ExternalNotificationModule.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "buzz/buzz.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "mesh/generated/meshtastic/rtttl.pb.h" #include diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index de3474aa2..c275ce1d9 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -1,11 +1,15 @@ #if !MESHTASTIC_EXCLUDE_PKI #include "KeyVerificationModule.h" +#include "CryptoEngine.h" +#include "HardwareRNG.h" #include "MeshService.h" -#include "RTC.h" +#include "gps/RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" #include "meshUtils.h" #include "modules/AdminModule.h" +#include "modules/NodeInfoModule.h" +#include #include KeyVerificationModule *keyVerificationModule; @@ -34,7 +38,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons { updateState(); if (request->which_payload_variant == meshtastic_AdminMessage_key_verification_tag && mp.from == 0) { - LOG_WARN("Handling Key Verification Admin Message type %u", request->key_verification.message_type); + LOG_DEBUG("Handling Key Verification Admin Message type %u", request->key_verification.message_type); if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_INITIATE_VERIFICATION && currentState == KEY_VERIFICATION_IDLE) { @@ -48,9 +52,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY && request->key_verification.nonce == currentNonce) { - auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (remoteNodePtr) - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + commitVerifiedRemoteNode(); resetToIdle(); } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) { resetToIdle(); @@ -63,9 +65,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r) { updateState(); - if (mp.pki_encrypted == false) { - return false; - } + // Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in + // the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below. if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply() return false; } @@ -74,9 +75,14 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & } if (currentState == KEY_VERIFICATION_SENDER_HAS_INITIATED && r->nonce == currentNonce && r->hash2.size == 32 && - r->hash1.size == 0) { + r->hash1.size == 32) { memcpy(hash2, r->hash2.bytes, 32); - IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, [](int number_picked) -> void { + // The response carries the responder's public key in hash1. If we don't already hold it, stash it + // as a pending key so the Router can PKI-encrypt our follow-on packet (committed to NodeDB on accept). + auto *responderNode = nodeDB->getMeshNode(currentRemoteNode); + if (responderNode == nullptr || responderNode->public_key.size != 32) + crypto->setPendingPublicKey(currentRemoteNode, r->hash1.bytes); + IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, false, [](uint32_t number_picked) -> void { keyVerificationModule->processSecurityNumber(number_picked); });) @@ -95,7 +101,8 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER; return true; - } else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && r->hash1.size == 32 && r->nonce == currentNonce) { + } else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && mp.pki_encrypted && r->hash1.size == 32 && + r->nonce == currentNonce) { if (memcmp(hash1, r->hash1.bytes, 32) == 0) { memset(message, 0, sizeof(message)); sprintf(message, "Verification: \n"); @@ -108,10 +115,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & options.notificationType = graphics::notificationTypeEnum::selection_picker; options.bannerCallback = [=](int selected) { + LOG_DEBUG("User selected %d for key verification", selected); if (selected == 1) { - auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (remoteNodePtr) - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + keyVerificationModule->commitVerifiedRemoteNode(); } }; screen->showOverlayBanner(options);) @@ -139,22 +145,29 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) { LOG_DEBUG("keyVerification start"); // generate nonce - updateState(); + updateState(false); if (currentState != KEY_VERIFICATION_IDLE) { IF_SCREEN(graphics::menuHandler::menuQueue = graphics::menuHandler::ThrottleMessage;) return false; } + updateState(true); currentNonce = random(); currentNonceTimestamp = getTime(); currentRemoteNode = remoteNode; meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero; KeyVerification.nonce = currentNonce; KeyVerification.hash2.size = 0; - KeyVerification.hash1.size = 0; + // Carry our public key in the otherwise-unused hash1 field so a peer that does not yet hold our + // key can learn it from this first message (bootstrap / onboarding). + KeyVerification.hash1.size = 32; + memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32); meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); p->to = remoteNode; p->channel = 0; - p->pki_encrypted = true; + // Only request PKI when we already hold the destination's key. Otherwise this first message goes out + // channel-encrypted (the Router falls back) so the peer can bootstrap from the key carried in hash1. + auto *remoteNodePtr = nodeDB->getMeshNode(remoteNode); + p->pki_encrypted = (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32); p->decoded.want_response = true; p->priority = meshtastic_MeshPacket_Priority_HIGH; service->sendToMesh(p, RX_SRC_LOCAL, true); @@ -171,9 +184,6 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period LOG_WARN("Key Verification requested, but already in a request"); return nullptr; - } else if (!currentRequest->pki_encrypted) { - LOG_WARN("Key Verification requested, but not in a PKI packet"); - return nullptr; } currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1; @@ -188,15 +198,43 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() response.nonce = scratch.nonce; currentRemoteNode = req.from; currentNonceTimestamp = getTime(); - currentSecurityNumber = random(1, 999999); + // The security number is the handshake's MitM-resistance entropy, so draw it from the hardware + // RNG (falling back to the CSPRNG used for key material), never the predictable random(). + // Hold cryptLock like xeddsa_sign does: on nRF52 the fill toggles the CC310 that packet crypto + // on the BLE task also uses, and the CryptRNG state is shared with the signing path. + uint32_t securityEntropy = 0; + { + concurrency::LockGuard g(cryptLock); + if (!HardwareRNG::fill((uint8_t *)&securityEntropy, sizeof(securityEntropy))) + CryptRNG.rand((uint8_t *)&securityEntropy, sizeof(securityEntropy)); + } + currentSecurityNumber = (securityEntropy % 999999) + 1; - // generate hash1 + // Resolve the requester's public key: from the PKI envelope, else carried in hash1 (bootstrap). + // Stash unknown keys as pending (committed to NodeDB only once verification is accepted). + const uint8_t *senderKey = nullptr; + if (currentRequest->pki_encrypted && currentRequest->public_key.size == 32) { + senderKey = currentRequest->public_key.bytes; // this is bizarre, fixme + } else if (scratch.hash1.size == 32) { + senderKey = scratch.hash1.bytes; + } + if (senderKey == nullptr) { + LOG_WARN("Key Verification request without a usable public key"); + resetToIdle(); + return nullptr; + } + auto *senderNode = nodeDB->getMeshNode(currentRemoteNode); + bool senderKeyInNodeDB = (senderNode != nullptr && senderNode->public_key.size == 32); + if (!senderKeyInNodeDB) + crypto->setPendingPublicKey(currentRemoteNode, senderKey); + + // generate local hash1 hash.reset(); hash.update(¤tSecurityNumber, sizeof(currentSecurityNumber)); hash.update(¤tNonce, sizeof(currentNonce)); hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); hash.update(&ourNodeNum, sizeof(ourNodeNum)); - hash.update(currentRequest->public_key.bytes, currentRequest->public_key.size); + hash.update(senderKey, 32); hash.update(owner.public_key.bytes, owner.public_key.size); hash.finalize(hash1, 32); @@ -205,15 +243,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() hash.update(¤tNonce, sizeof(currentNonce)); hash.update(hash1, 32); hash.finalize(hash2, 32); - response.hash1.size = 0; + // Carry our public key in hash1 of the response so the requester can bootstrap our key as well. + response.hash1.size = 32; + memcpy(response.hash1.bytes, owner.public_key.bytes, 32); response.hash2.size = 32; memcpy(response.hash2.bytes, hash2, 32); responsePacket = allocDataProtobuf(response); - responsePacket->pki_encrypted = true; + // PKI-encrypt the response only if we already held the requester's key. In the bootstrap case it goes + // out channel-encrypted so the requester (who lacks our key) can decode it and read hash1. + responsePacket->pki_encrypted = senderKeyInNodeDB; IF_SCREEN(snprintf(message, 25, "Security Number \n%03u %03u", currentSecurityNumber / 1000, currentSecurityNumber % 1000); - screen->showSimpleBanner(message, 30000); LOG_WARN("%s", message);) + screen->showSimpleBanner(message, 30000); LOG_DEBUG("%s", message);) meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); if (cn) { cn->level = meshtastic_LogRecord_Level_WARNING; @@ -227,7 +269,7 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber; service->sendClientNotification(cn); } - LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); + LOG_DEBUG("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); return responsePacket; } @@ -236,14 +278,18 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) SHA256 hash; NodeNum ourNodeNum = nodeDB->getNodeNum(); uint8_t scratch_hash[32] = {0}; - LOG_WARN("received security number: %u", incomingNumber); - meshtastic_NodeInfoLite *remoteNodePtr = nullptr; - remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (!remoteNodePtr || !nodeInfoLiteHasUser(remoteNodePtr) || remoteNodePtr->public_key.size != 32) { - currentState = KEY_VERIFICATION_IDLE; - return; // should we throw an error here? + LOG_DEBUG("received security number: %u", incomingNumber); + meshtastic_NodeInfoLite *remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); + // Resolve the remote public key: NodeDB if known, otherwise the pending key learned during this + // handshake (bootstrap case). + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + if (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32) { + remotePublic = remoteNodePtr->public_key; + } else if (!crypto->getPendingPublicKey(currentRemoteNode, remotePublic)) { + LOG_WARN("No public key available for remote node, aborting key verification"); + resetToIdle(); + return; } - LOG_WARN("hashing "); // calculate hash1 hash.reset(); hash.update(&incomingNumber, sizeof(incomingNumber)); @@ -252,7 +298,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); hash.update(owner.public_key.bytes, owner.public_key.size); - hash.update(remoteNodePtr->public_key.bytes, remoteNodePtr->public_key.size); + hash.update(remotePublic.bytes, 32); hash.finalize(hash1, 32); hash.reset(); @@ -297,13 +343,13 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) return; } -void KeyVerificationModule::updateState() +void KeyVerificationModule::updateState(bool resetTimer) { if (currentState != KEY_VERIFICATION_IDLE) { // check for the 60 second timeout if (currentNonceTimestamp < getTime() - 60) { resetToIdle(); - } else { + } else if (resetTimer) { currentNonceTimestamp = getTime(); } } @@ -318,6 +364,32 @@ void KeyVerificationModule::resetToIdle() currentSecurityNumber = 0; currentRemoteNode = 0; currentState = KEY_VERIFICATION_IDLE; + // Discard any not-yet-verified key learned during this handshake; on reject/timeout it is never trusted. + crypto->clearPendingPublicKey(); +} + +void KeyVerificationModule::commitVerifiedRemoteNode() +{ + // The remote node already has a NodeDB entry by this point (packets were exchanged during the + // handshake), so getMeshNode is sufficient; bail defensively if it is somehow absent. + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentRemoteNode); + if (!node) { + LOG_WARN("Attempted to commit key, but unknown node"); + return; + } + // If we only held the peer's key as a pending (unverified) key during the handshake, commit it to + // NodeDB now that the user has confirmed the verification, so future PKI traffic can use it. + meshtastic_NodeInfoLite_public_key_t pending = {0, {0}}; + if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending)) + node->public_key = pending; + node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber); + if (nodeInfoModule) + nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true); + crypto->clearPendingPublicKey(); + currentState = KEY_VERIFICATION_IDLE; + // Persist the committed key and verified flag so manual verification survives a reboot. + nodeDB->saveToDisk(SEGMENT_NODEDATABASE); } void KeyVerificationModule::generateVerificationCode(char *readableCode) diff --git a/src/modules/KeyVerificationModule.h b/src/modules/KeyVerificationModule.h index d5dba01d7..9496649d3 100644 --- a/src/modules/KeyVerificationModule.h +++ b/src/modules/KeyVerificationModule.h @@ -12,6 +12,39 @@ enum KeyVerificationState { KEY_VERIFICATION_RECEIVER_AWAITING_HASH1, }; +// KeyVerification Module overview +// This module allows for two useful functions. First, it implements a 2sv process that can manually verify a trustworthy +// connection with another node. It specifically verifies that the other node holds the correct private key for its public key, so +// it is resistant to MitM attacks. Second, it can be used to bootstrap trust in a new node by carrying the public key in the +// initial unencrypted message (in the hash1 field of the KeyVerification protobuf). This allows a user to manually verify a new +// node even if they don't have that node in the local nodeDB at all. + +// The handshake process is as follows (NodeA = initiator, NodeB = responder): +// 1. NodeA sends a KeyVerification message containing a random nonce and its own public key (in the +// hash1 field) to NodeB. Implemented in sendInitialRequest(). It is PKI-encrypted if NodeA already +// holds NodeB's key, otherwise channel-encrypted (the bootstrap case). +// +// 2. NodeB replies (allocReply()) with its own public key (hash1 field) and hash2. NodeB generates a +// random 6-digit security number and stashes NodeA's public key (as a pending key if not already in +// the nodeDB). It computes hash1 = SHA256(securityNumber, nonce, NodeA_num, NodeB_num, PK_A, PK_B), +// then hash2 = SHA256(nonce, hash1). The reply is PKI-encrypted only if NodeB already held NodeA's +// key; in the bootstrap case it is channel-encrypted so NodeA can read NodeB's key from hash1. +// +// 3. NodeA receives the reply (handleReceivedProtobuf()), checks the nonce, stashes NodeB's public key, +// and prompts the user to enter the security number. The security number is never sent over the mesh +// and must be communicated over a secondary channel. processSecurityNumber() recomputes hash1 from +// the entered number and verifies SHA256(nonce, hash1) matches the received hash2. NodeA then sends +// its hash1 back to NodeB in a PKI-encrypted KeyVerification message (the follow-on PKI packet) and +// shows the KeyVerificationFinalPrompt menu, displaying 8 characters derived from hash1. +// +// 4. NodeB receives NodeA's hash1 (handleReceivedProtobuf(); required to be PKI-encrypted), checks it +// matches the hash1 NodeB generated, and shows the same 8-character code for final confirmation. +// +// The final on-screen code comparison is the actual manual verification: the user confirms the codes +// match on both devices, proving the two nodes agree on the same public keys (no MitM substitution). +// PKI-encrypting the follow-on packet additionally proves each node holds the private key for the +// agreed public key. + class KeyVerificationModule : public ProtobufModule //, private concurrency::OSThread // { // CallbackObserver nodeStatusObserver = @@ -29,6 +62,7 @@ class KeyVerificationModule : public ProtobufModule bool sendInitialRequest(NodeNum remoteNode); void generateVerificationCode(char *); // fills char with the user readable verification code uint32_t getCurrentRemoteNode() { return currentRemoteNode; } + void commitVerifiedRemoteNode(); // Commit a pending key to NodeDB and mark the node manually verified protected: /* Called to handle a particular incoming message @@ -58,8 +92,8 @@ class KeyVerificationModule : public ProtobufModule char message[40] = {0}; void processSecurityNumber(uint32_t); - void updateState(); // check the timeouts and maybe reset the state to idle - void resetToIdle(); // Zero out module state + void updateState(bool resetTimer = true); // check the timeouts and maybe reset the state to idle + void resetToIdle(); // Zero out module state }; extern KeyVerificationModule *keyVerificationModule; \ No newline at end of file diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 6ab917ef9..2e9d865a6 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -2,11 +2,11 @@ #include "Default.h" #include "DisplayFormatters.h" #include "NodeDB.h" -#include "RTC.h" #include "RadioInterface.h" #include "Router.h" #include "TransmitHistory.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include #include diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 1e9380575..546651c5b 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -19,6 +19,9 @@ #if !MESHTASTIC_EXCLUDE_CANNEDMESSAGES #include "modules/CannedMessageModule.h" #endif +#if HAS_SCREEN && BASEUI_HAS_GAMES +#include "modules/games/GamesModule.h" +#endif #if !MESHTASTIC_EXCLUDE_DETECTIONSENSOR #include "modules/DetectionSensorModule.h" #endif @@ -206,6 +209,11 @@ void setupModules() cannedMessageModule = new CannedMessageModule(); } #endif +#if HAS_SCREEN && BASEUI_HAS_GAMES + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + gamesModule = new GamesModule(); + } +#endif #if ARCH_PORTDUINO new HostMetricsModule(); #endif diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index 6c4b452ba..a626bbcaa 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -2,7 +2,7 @@ #include "Default.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" +#include "gps/RTC.h" #include NeighborInfoModule *neighborInfoModule; diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index c86c54aff..0e40daf10 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -3,10 +3,10 @@ #include "MeshService.h" #include "NodeDB.h" #include "NodeStatus.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include #include diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index d3a702946..18931da12 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -5,13 +5,13 @@ #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "TypeConversions.h" #include "airtime.h" #include "configuration.h" #include "gps/GeoCoord.h" +#include "gps/RTC.h" #include "main.h" #include "meshUtils.h" #include "meshtastic/atak.pb.h" diff --git a/src/modules/PowerStressModule.cpp b/src/modules/PowerStressModule.cpp index 1c073a10a..b818e8895 100644 --- a/src/modules/PowerStressModule.cpp +++ b/src/modules/PowerStressModule.cpp @@ -2,9 +2,9 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerMon.h" -#include "RTC.h" #include "Router.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "sleep.h" #include "target_specific.h" diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index 57001919d..a02f0594e 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -13,12 +13,12 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "SPILock.h" #include "airtime.h" #include "configuration.h" #include "gps/GeoCoord.h" +#include "gps/RTC.h" #include #include @@ -154,7 +154,7 @@ ProcessMessage RangeTestModuleRadio::handleReceived(const meshtastic_MeshPacket NodeInfoLite *n = nodeDB->getMeshNode(getFrom(&mp)); LOG_DEBUG("-----------------------------------------"); - LOG_DEBUG("p.payload.bytes \"%s\"", p.payload.bytes); + LOG_DEBUG("p.payload.bytes \"%.*s\"", (int)p.payload.size, p.payload.bytes); LOG_DEBUG("p.payload.size %d", p.payload.size); LOG_DEBUG("---- Received Packet:"); LOG_DEBUG("mp.from %d", mp.from); @@ -192,7 +192,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) meshtastic_NodeInfoLite *n = nodeDB->getMeshNode(getFrom(&mp)); /* LOG_DEBUG("-----------------------------------------"); - LOG_DEBUG("p.payload.bytes \"%s\"", p.payload.bytes); + LOG_DEBUG("p.payload.bytes \"%.*s\"", (int)p.payload.size, p.payload.bytes); LOG_DEBUG("p.payload.size %d", p.payload.size); LOG_DEBUG("---- Received Packet:"); LOG_DEBUG("mp.from %d", mp.from); @@ -307,7 +307,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) fileToAppend.printf("%d,", mp.hop_limit); // Packet Hop Limit // TODO: If quotes are found in the payload, it has to be escaped. - fileToAppend.printf("\"%s\"\n", p.payload.bytes); + fileToAppend.printf("\"%.*s\"\n", (int)p.payload.size, p.payload.bytes); fileToAppend.printf("%i,", mp.rx_rssi); // RX RSSI fileToAppend.flush(); diff --git a/src/modules/RemoteHardwareModule.cpp b/src/modules/RemoteHardwareModule.cpp index 04cfeb651..ef2b23c8f 100644 --- a/src/modules/RemoteHardwareModule.cpp +++ b/src/modules/RemoteHardwareModule.cpp @@ -1,9 +1,9 @@ #include "RemoteHardwareModule.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index 23e8e9790..aab79ffd5 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -3,9 +3,9 @@ #include "MeshService.h" #include "NMEAWPL.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "configuration.h" +#include "gps/RTC.h" #include #include @@ -396,7 +396,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp lastRxID = mp.id; // LOG_DEBUG("* * Message came this device"); // serialPrint->println("* * Message came this device"); - serialPrint->printf("%s", p.payload.bytes); + serialPrint->printf("%.*s", (int)p.payload.size, p.payload.bytes); } } } else { @@ -408,7 +408,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); const char *sender = nodeInfoLiteHasUser(node) ? node->short_name : "???"; serialPrint->println(); - serialPrint->printf("%s: %s", sender, p.payload.bytes); + serialPrint->printf("%s: %.*s", sender, (int)p.payload.size, p.payload.bytes); serialPrint->println(); } else if ((moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_NMEA || moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO) && diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index 413006d6e..3254d11a3 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -15,11 +15,11 @@ #include "StoreForwardModule.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "Throttle.h" #include "airtime.h" #include "configuration.h" +#include "gps/RTC.h" #include "memGet.h" #include "mesh-pb-constants.h" #include "mesh/generated/meshtastic/storeforward.pb.h" diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index ceef4dbb6..7ab0ed3d4 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -9,11 +9,11 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "UnitConversions.h" #include "detect/ScanI2CTwoWire.h" +#include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 06e220296..d8f17963d 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -4,11 +4,11 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" -#include "RTC.h" #include "RadioLibInterface.h" #include "Router.h" #include "TransmitHistory.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "memGet.h" #include diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 876d9ca07..63273239d 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -9,11 +9,11 @@ #include "NodeDB.h" #include "Power.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "UnitConversions.h" #include "buzz.h" +#include "gps/RTC.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" #include "main.h" diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index 56700010f..f68c92e1b 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -9,10 +9,10 @@ #include "NodeDB.h" #include "Power.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "UnitConversions.h" +#include "gps/RTC.h" #include "main.h" #include "sleep.h" #include "target_specific.h" diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 3fa111e43..816f02898 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -9,9 +9,9 @@ #include "Power.h" #include "PowerFSM.h" #include "PowerTelemetry.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" +#include "gps/RTC.h" #include "graphics/SharedUIDisplay.h" #include "main.h" #include "sleep.h" diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.h b/src/modules/Telemetry/Sensor/PMSA003ISensor.h index 9b9cad37a..419c28bd9 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.h +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.h @@ -4,8 +4,8 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" +#include "gps/RTC.h" #define PMSA003I_I2C_CLOCK_SPEED 100000 #define PMSA003I_FRAME_LENGTH 32 diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h index 0e1a46f44..065c82a13 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.h +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -4,8 +4,8 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" +#include "gps/RTC.h" #include // Max speed 400kHz diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.h b/src/modules/Telemetry/Sensor/SEN5XSensor.h index 935c8cb21..5d84b8916 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.h +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h @@ -4,9 +4,9 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" #include "Wire.h" +#include "gps/RTC.h" // Warm up times for SEN5X from the datasheet #ifndef SEN5X_WARMUP_MS_1 diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.h b/src/modules/Telemetry/Sensor/SFA30Sensor.h index 009138b90..a72bef252 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.h +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.h @@ -4,8 +4,8 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" +#include "gps/RTC.h" #include #define SFA30_I2C_CLOCK_SPEED 100000 diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 89ad128cc..74e8de6c2 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -726,9 +726,16 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack return ProcessMessage::CONTINUE; } + // A known signer's NodeInfo arriving unsigned is unauthenticated (the sender is forgeable), so + // it must not drive any cache or identity write below. Computed once here (getMeshNode is O(N)) + // and reused by both the cache-refresh and the direct-response identity path. + const bool isNodeInfo = mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP; + const meshtastic_NodeInfoLite *senderNode = isNodeInfo ? nodeDB->getMeshNode(getFrom(&mp)) : nullptr; + const bool unauthenticatedSigner = senderNode && nodeInfoLiteHasXeddsaSigned(senderNode) && !mp.xeddsa_signed; + // Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3 // role cache for any node we already track (keeps the dedup role exception current). - if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) { + if (isNodeInfo && !unauthenticatedSigner) { cacheNodeInfoPacket(mp); updateCachedRoleFromNodeInfo(mp); } @@ -744,8 +751,12 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack if (cfg.nodeinfo_direct_response_max_hops > 0 && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { if (shouldRespondToNodeInfo(&mp, true)) { + // Unicast NodeInfo is never signed, so a known signer's identity claim here is + // unauthenticated: don't overwrite its stored name. The cached response is unaffected. + // unauthenticatedSigner was computed above (this branch is NodeInfo-only). meshtastic_User requester = meshtastic_User_init_zero; - if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { + if (!unauthenticatedSigner && + pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { nodeDB->updateUser(getFrom(&mp), requester, mp.channel); } logAction("respond", &mp, "nodeinfo-cache"); diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index b1d0f9d9a..815e426a2 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -4,8 +4,8 @@ #include "FSCommon.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" +#include "gps/RTC.h" /* AudioModule @@ -68,23 +68,23 @@ void run_codec2(void *parameter) } if (audioModule->radio_state == RadioState::rx) { size_t bytesOut = 0; - if (memcmp(audioModule->rx_encode_frame, &audioModule->tx_header, sizeof(audioModule->tx_header)) == 0) { - for (int i = 4; i < audioModule->rx_encode_frame_index; i += audioModule->encode_codec_size) { + // Reject frames not carrying our own header (magic + mode); an invalid mode byte + // would else NULL-deref via codec2_create. The bound keeps reads inside the buffer. + const int headerLen = sizeof(audioModule->tx_header); + const int frameSize = audioModule->encode_codec_size; + int limit = audioModule->rx_encode_frame_index; + if (limit > (int)sizeof(audioModule->rx_encode_frame)) + limit = sizeof(audioModule->rx_encode_frame); + if (frameSize > 0 && limit >= headerLen && + memcmp(audioModule->rx_encode_frame, &audioModule->tx_header, headerLen) == 0) { + for (int i = headerLen; i + frameSize <= limit; i += frameSize) { codec2_decode(audioModule->codec2, audioModule->output_buffer, audioModule->rx_encode_frame + i); - i2s_write(I2S_PORT, &audioModule->output_buffer, audioModule->adc_buffer_size, &bytesOut, - pdMS_TO_TICKS(500)); + // adc_buffer_size is a sample count; i2s_write wants bytes (int16_t samples). + i2s_write(I2S_PORT, &audioModule->output_buffer, audioModule->adc_buffer_size * sizeof(int16_t), + &bytesOut, pdMS_TO_TICKS(500)); } } else { - // if the buffer header does not match our own codec, make a temp decoding setup. - CODEC2 *tmp_codec2 = codec2_create(audioModule->rx_encode_frame[3]); - codec2_set_lpc_post_filter(tmp_codec2, 1, 0, 0.8, 0.2); - int tmp_encode_codec_size = (codec2_bits_per_frame(tmp_codec2) + 7) / 8; - int tmp_adc_buffer_size = codec2_samples_per_frame(tmp_codec2); - for (int i = 4; i < audioModule->rx_encode_frame_index; i += tmp_encode_codec_size) { - codec2_decode(tmp_codec2, audioModule->output_buffer, audioModule->rx_encode_frame + i); - i2s_write(I2S_PORT, &audioModule->output_buffer, tmp_adc_buffer_size, &bytesOut, pdMS_TO_TICKS(500)); - } - codec2_destroy(tmp_codec2); + LOG_WARN("Audio: dropping frame with mismatched or short codec2 header"); } } } @@ -213,16 +213,18 @@ int32_t AudioModule::runOnce() } } if (radio_state == RadioState::tx) { - // Get I2S data from the microphone and place in data buffer + // Get I2S data from the microphone and place in data buffer. adc_buffer_size is a + // sample count; i2s_read and adc_buffer_index are in bytes, so scale by int16_t. size_t bytesIn = 0; - res = i2s_read(I2S_PORT, adc_buffer + adc_buffer_index, adc_buffer_size - adc_buffer_index, &bytesIn, + const size_t frameBytes = adc_buffer_size * sizeof(uint16_t); + res = i2s_read(I2S_PORT, (uint8_t *)adc_buffer + adc_buffer_index, frameBytes - adc_buffer_index, &bytesIn, pdMS_TO_TICKS(40)); // wait 40ms for audio to arrive. if (res == ESP_OK) { adc_buffer_index += bytesIn; - if (adc_buffer_index == adc_buffer_size) { + if (adc_buffer_index >= frameBytes) { adc_buffer_index = 0; - memcpy((void *)speech, (void *)adc_buffer, 2 * adc_buffer_size); + memcpy((void *)speech, (void *)adc_buffer, frameBytes); // Notify run_codec2 task that the buffer is ready. radio_state = RadioState::tx; BaseType_t xHigherPriorityTaskWoken = pdFALSE; @@ -273,9 +275,12 @@ ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp) if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { auto &p = mp.decoded; if (!isFromUs(&mp)) { - memcpy(rx_encode_frame, p.payload.bytes, p.payload.size); + size_t n = p.payload.size; + if (n > sizeof(rx_encode_frame)) // bound a crafted payload to the RX buffer + n = sizeof(rx_encode_frame); + memcpy(rx_encode_frame, p.payload.bytes, n); radio_state = RadioState::rx; - rx_encode_frame_index = p.payload.size; + rx_encode_frame_index = n; // Notify run_codec2 task that the buffer is ready. BaseType_t xHigherPriorityTaskWoken = pdFALSE; vTaskNotifyGiveFromISR(codec2HandlerTask, &xHigherPriorityTaskWoken); diff --git a/src/modules/games/Breakout.cpp b/src/modules/games/Breakout.cpp new file mode 100644 index 000000000..f60f4d8bf --- /dev/null +++ b/src/modules/games/Breakout.cpp @@ -0,0 +1,315 @@ +#include "Breakout.h" + +// =========================================================================== +// Pure BreakoutGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +uint32_t BreakoutGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +void BreakoutGame::buildBricks() +{ + for (uint8_t r = 0; r < BRICK_ROWS; r++) + for (uint8_t c = 0; c < BRICK_COLS; c++) + bricks[r][c] = 1; + bricksLeft = static_cast(BRICK_ROWS) * BRICK_COLS; +} + +void BreakoutGame::serveBall() +{ + // Centre the paddle and launch the ball upward from just above it, at a slight angle whose + // side is chosen randomly so successive serves are not identical. + paddleLeft = (BOARD_W - PADDLE_W) / 2; + ballPxX = static_cast(BOARD_W / 2) * SUBPX; + ballPxY = static_cast(PADDLE_Y - 2) * SUBPX; + ballVx = (nextRandom() & 1u) ? 28 : -28; + ballVy = -BALL_VY; +} + +void BreakoutGame::nextLevel() +{ + levelNum++; + buildBricks(); + serveBall(); +} + +void BreakoutGame::reset(uint32_t seed) +{ + rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0 + points = 0; + livesLeft = START_LIVES; + levelNum = 1; + alive = true; + ballTick = false; + buildBricks(); + serveBall(); +} + +void BreakoutGame::movePaddle(int16_t dxPx) +{ + if (!alive) + return; + paddleLeft += dxPx; + if (paddleLeft < 0) + paddleLeft = 0; + else if (paddleLeft > BOARD_W - PADDLE_W) + paddleLeft = BOARD_W - PADDLE_W; +} + +void BreakoutGame::moveLeft() +{ + movePaddle(-PADDLE_STEP); +} + +void BreakoutGame::moveRight() +{ + movePaddle(PADDLE_STEP); +} + +bool BreakoutGame::step() +{ + if (!alive) + return false; + + // The ball advances on every other step() so the caller can tick (and poll the paddle) at twice + // the ball's rate -- this keeps the ball speed constant while paddle control refreshes faster. + ballTick = !ballTick; + if (!ballTick) + return true; + + ballPxX += ballVx; + ballPxY += ballVy; + + int16_t px = static_cast(ballPxX / SUBPX); + int16_t py = static_cast(ballPxY / SUBPX); + + // Side walls. + if (px <= 0) { + ballPxX = 0; + px = 0; + ballVx = -ballVx; + } else if (px >= BOARD_W - 1) { + ballPxX = static_cast(BOARD_W - 1) * SUBPX; + px = BOARD_W - 1; + ballVx = -ballVx; + } + // Top wall. + if (py <= 0) { + ballPxY = 0; + py = 0; + ballVy = -ballVy; + } + + // Bricks: at most one brick per step (single, blocky reflection off the bottom/top face). + if (py >= BRICK_TOP && py < BRICK_TOP + BRICK_ROWS * BRICK_H) { + const int r = (py - BRICK_TOP) / BRICK_H; + const int c = px / BRICK_W; + if (r >= 0 && r < BRICK_ROWS && c >= 0 && c < BRICK_COLS && bricks[r][c]) { + bricks[r][c] = 0; + bricksLeft--; + points += POINTS_PER_BRICK; + ballVy = -ballVy; + if (bricksLeft == 0) { + nextLevel(); + return true; + } + } + } + + // Paddle: bounce up and steer horizontally based on where the ball struck. + if (ballVy > 0 && py >= PADDLE_Y - 1 && py <= PADDLE_Y + PADDLE_H) { + if (px >= paddleLeft && px < paddleLeft + PADDLE_W) { + ballPxY = static_cast(PADDLE_Y - 1) * SUBPX; + ballVy = -BALL_VY; + // Six zones across the paddle map to increasing outward angles; no zone is vertical. + static const int16_t vxByZone[6] = {-48, -28, -8, 8, 28, 48}; + int zone = ((px - paddleLeft) * 6) / PADDLE_W; + if (zone < 0) + zone = 0; + else if (zone > 5) + zone = 5; + ballVx = vxByZone[zone]; + } + } + + // Ball lost past the bottom edge. + if (py >= BOARD_H) { + if (livesLeft > 0) + livesLeft--; + if (livesLeft == 0) { + alive = false; + return false; + } + serveBall(); + } + + return alive; +} + +// =========================================================================== +// Breakout adapter (display + persistence; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" +#if ARCH_PORTDUINO && defined(__linux__) +#include "input/LinuxJoystick.h" +#endif + +// Paddle pixels moved per tick while a joystick direction is held (continuous polling path). +static constexpr int16_t PADDLE_POLL_STEP = 3; + +#if GRAPHICS_TFT_COLORING_ENABLED +// Classic Breakout brick-wall colours, top row to bottom. +static uint16_t brickRowColor(uint8_t row) +{ + using namespace graphics; + switch (row) { + case 0: + return TFTPalette::Red; + case 1: + return TFTPalette::Orange; + case 2: + return TFTPalette::Yellow; + case 3: + return TFTPalette::Green; + default: + return TFTPalette::Cyan; + } +} +#endif + +Breakout::Breakout() +{ + scores_.load(); +} + +int32_t Breakout::tickIntervalMs() const +{ + // Tick at twice the ball's cadence: the ball advances every other step() (BreakoutGame::step), + // so halving the interval keeps the ball speed the same while the paddle is polled/redrawn twice + // as often. Speed ramps with level: ~22 ms base, floor 10 ms. + int32_t iv = 45 - static_cast(game.level() - 1) * 5; + if (iv < 20) + iv = 20; + return iv / 2; +} + +bool Breakout::tick() +{ +#if ARCH_PORTDUINO && defined(__linux__) + // Poll the joystick's held direction directly so the paddle glides continuously instead of + // creeping along at the D-pad's slow auto-repeat rate. + if (aLinuxJoystick) { + const int held = aLinuxJoystick->heldXZone(); + if (held < 0) + game.movePaddle(-PADDLE_POLL_STEP); + else if (held > 0) + game.movePaddle(PADDLE_POLL_STEP); + } +#endif + return game.step(); +} + +void Breakout::handleInput(input_broker_event ev) +{ +#if ARCH_PORTDUINO && defined(__linux__) + // When a joystick is present the paddle is polled continuously in tick(); ignore the discrete + // (and slow) repeat events so we don't double-move. + if (aLinuxJoystick) + return; +#endif + switch (ev) { + case INPUT_BROKER_LEFT: + game.moveLeft(); + break; + case INPUT_BROKER_RIGHT: + game.moveRight(); + break; + default: + break; + } +} + +void Breakout::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "B R E A K O U T"); + const int16_t logoX = x + (w - breakout_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, breakout_width, breakout_height, breakout); +#if GRAPHICS_TFT_COLORING_ENABLED + // The glyph is three brick courses, a ball, and a paddle -- colour each to match the game. + const uint16_t abg = graphics::getThemeBodyBg(); + graphics::registerTFTColorRegionDirect(logoX, logoY + 1, breakout_width, 2, graphics::TFTPalette::Red, abg); + graphics::registerTFTColorRegionDirect(logoX, logoY + 4, breakout_width, 2, graphics::TFTPalette::Yellow, abg); + graphics::registerTFTColorRegionDirect(logoX, logoY + 7, breakout_width, 2, graphics::TFTPalette::Green, abg); + graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 14, 8, 2, graphics::TFTPalette::Blue, abg); // paddle + graphics::registerTFTColorRegionDirect(logoX + 7, logoY + 10, 2, 2, graphics::TFTPalette::White, abg); // ball +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Score row (top-left), remaining lives as small squares (top-right). + char buf[16]; + display->setTextAlignment(TEXT_ALIGN_LEFT); + snprintf(buf, sizeof(buf), "Sc %lu", static_cast(game.score())); + display->drawString(x + 2, y, buf); + for (uint8_t i = 0; i < game.lives(); i++) + display->fillRect(x + display->getWidth() - 3 - i * 4, y + 2, 2, 2); + + // Bricks. + for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++) + for (uint8_t c = 0; c < BreakoutGame::BRICK_COLS; c++) + if (game.brickAt(r, c)) + display->fillRect(x + c * BreakoutGame::BRICK_W, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H, + BreakoutGame::BRICK_W - 1, BreakoutGame::BRICK_H - 1); + + // Paddle. + display->fillRect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W, BreakoutGame::PADDLE_H); + + // Ball. + display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2); + +#if GRAPHICS_TFT_COLORING_ENABLED + // Colour the wall by row, plus a blue paddle and white ball. One region per brick row (the row's + // lit bricks take the colour; cleared cells and gaps stay background). Paddle then ball register + // last so the ball always wins where it overlaps. + const uint16_t bg = graphics::getThemeBodyBg(); + for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++) + graphics::registerTFTColorRegionDirect(x, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H, BreakoutGame::BOARD_W, + BreakoutGame::BRICK_H - 1, brickRowColor(r), bg); + graphics::registerTFTColorRegionDirect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W, + BreakoutGame::PADDLE_H, graphics::TFTPalette::Blue, bg); + graphics::registerTFTColorRegionDirect(x + game.ballX(), y + game.ballY(), 2, 2, graphics::TFTPalette::White, bg); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Breakout.h b/src/modules/games/Breakout.h new file mode 100644 index 000000000..9c4fe8dfb --- /dev/null +++ b/src/modules/games/Breakout.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include + +/** + * Pure, self-contained Breakout game logic. + * + * No Arduino/display dependencies - designed to be unit-tested natively (see test/test_breakout) + * and reused by the Breakout adapter below without pulling in the display stack. + * + * The board is a fixed BOARD_W x BOARD_H pixel field. A grid of bricks sits near the top, a paddle + * slides along the bottom, and a ball bounces between them. Ball position/velocity are tracked in + * fixed-point sub-pixels (SUBPX per pixel) so movement is smooth and deterministic; everything is + * integer math and statically sized (no heap). + */ +class BreakoutGame +{ + public: + // Playfield, in pixels (a standard 128x64 OLED in landscape). + static constexpr int16_t BOARD_W = 128; + static constexpr int16_t BOARD_H = 64; + + // Brick grid. + static constexpr uint8_t BRICK_COLS = 8; + static constexpr uint8_t BRICK_ROWS = 5; + static constexpr int16_t BRICK_W = BOARD_W / BRICK_COLS; // 16 + static constexpr int16_t BRICK_H = 4; + static constexpr int16_t BRICK_TOP = 12; // top of the first course; leaves room for the score row + + // Paddle. + static constexpr int16_t PADDLE_W = 24; + static constexpr int16_t PADDLE_H = 2; + static constexpr int16_t PADDLE_Y = BOARD_H - 3; // top edge of the paddle + static constexpr int16_t PADDLE_STEP = 6; // pixels moved per input event + + static constexpr uint8_t START_LIVES = 3; + static constexpr uint8_t POINTS_PER_BRICK = 10; + + /** (Re)start a full game: rebuild bricks, reset lives/score, serve the ball. `seed` drives the + * xorshift32 RNG used for the initial serve direction. */ + void reset(uint32_t seed); + + /** Advance the simulation by one tick (move the ball, resolve collisions). Returns true if the + * game is still in progress, false once the last life is lost. No-op returning false once dead. */ + bool step(); + + /** Slide the paddle; the ball is unaffected. moveLeft/moveRight use the default per-press step; + * movePaddle takes an explicit signed pixel delta (used when polling a held joystick). */ + void movePaddle(int16_t dxPx); + void moveLeft(); + void moveRight(); + + bool isPlaying() const { return alive; } + uint32_t score() const { return points; } + uint8_t lives() const { return livesLeft; } + uint8_t level() const { return levelNum; } + uint16_t bricksRemaining() const { return bricksLeft; } + + // --- Rendering accessors (all in board pixels) --- + int16_t paddleX() const { return paddleLeft; } + int16_t ballX() const { return static_cast(ballPxX / SUBPX); } + int16_t ballY() const { return static_cast(ballPxY / SUBPX); } + bool brickAt(uint8_t row, uint8_t col) const { return row < BRICK_ROWS && col < BRICK_COLS && bricks[row][col]; } + + private: + static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel + static constexpr int32_t BALL_VY = 40; // vertical ball speed (sub-pixels/step) + static constexpr int16_t BALL_SIZE = 2; // ball is drawn BALL_SIZE x BALL_SIZE + + void buildBricks(); + void serveBall(); + void nextLevel(); + uint32_t nextRandom(); + + uint8_t bricks[BRICK_ROWS][BRICK_COLS] = {0}; // 0 == cleared, 1 == present + uint16_t bricksLeft = 0; + + int16_t paddleLeft = 0; // left edge of the paddle, in pixels + + int32_t ballPxX = 0, ballPxY = 0; // ball centre, in sub-pixels + int32_t ballVx = 0, ballVy = 0; // ball velocity, in sub-pixels/step + + uint32_t points = 0; + uint8_t livesLeft = 0; + uint8_t levelNum = 1; + uint32_t rng = 1; // xorshift32 state (never 0) + bool alive = false; + bool ballTick = false; // ball advances on every other step() (see step()) +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Breakout as a hosted Game. Wraps the pure BreakoutGame logic above and supplies the attract art, + * the playfield renderer, the paddle input, the level-based speed curve, and its own local + * high-score table. No mesh protocol (scores are local-only). + */ +class Breakout : public Game +{ + public: + Breakout(); + + const char *name() const override { return "Breakout"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override; // polls a held joystick for the paddle, then advances the ball + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override; + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record. New file (magic 'BRKT', version 1); layout mirrors Snake's. + struct BreakoutEntry { + uint32_t score; + uint32_t nodeNum; + char shortName[5]; // three characters, NUL-terminated + uint32_t epoch; // getValidTime(), 0 if no RTC + } __attribute__((packed)); + + BreakoutGame game; + HighScoreTable scores_{"/prefs/breakout.dat", 0x424B5254u, 1, "Breakout"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/ChirpyRunner.cpp b/src/modules/games/ChirpyRunner.cpp new file mode 100644 index 000000000..4e1a18521 --- /dev/null +++ b/src/modules/games/ChirpyRunner.cpp @@ -0,0 +1,303 @@ +#include "ChirpyRunner.h" + +// =========================================================================== +// Pure ChirpyRunnerGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +uint32_t ChirpyRunnerGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +int16_t ChirpyRunnerGame::pickGapSteps() +{ + return static_cast(GAP_STEPS_MIN + static_cast(nextRandom() % (GAP_STEPS_MAX - GAP_STEPS_MIN + 1))); +} + +void ChirpyRunnerGame::resetClouds() +{ + // Spread the clouds across the sky at staggered x, at varied heights near the top. + for (uint8_t i = 0; i < CLOUD_COUNT; i++) { + cloud[i].xSub = static_cast(i * (BOARD_W / CLOUD_COUNT) + 6) * SUBPX; + cloud[i].y = static_cast(10 + nextRandom() % 10u); // 10..19 (below the score row) + } +} + +void ChirpyRunnerGame::scrollClouds() +{ + // Slow parallax drift; wrap back to the right (at a fresh height) once off the left edge. + for (uint8_t i = 0; i < CLOUD_COUNT; i++) { + cloud[i].xSub -= CLOUD_SPEED_SUB; + if (cloud[i].xSub / SUBPX + CLOUD_W < 0) { + cloud[i].xSub = static_cast(BOARD_W + nextRandom() % 24u) * SUBPX; + cloud[i].y = static_cast(10 + nextRandom() % 10u); + } + } +} + +void ChirpyRunnerGame::spawnObstacle() +{ + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) { + if (obst[i].active) + continue; + obst[i].active = true; + obst[i].scored = false; + obst[i].xSub = static_cast(BOARD_W) * SUBPX; + obst[i].w = OBST_W; + // Three height tiers so timing varies (kept clearable with margin for a forgiving jump). + const uint32_t tier = nextRandom() % 3u; + obst[i].h = BUILDING_HEIGHTS[tier]; + obst[i].colorIdx = static_cast((spawnCount / 10u) % OBST_COLOR_COUNT); + spawnCount++; + return; + } +} + +void ChirpyRunnerGame::reset(uint32_t seed) +{ + rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0 + points = 0; + alive = true; + chirpyTop = groundedTopSub(); + vy = 0; + jumpGravity = GRAVITY; + grounded = true; + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) + obst[i] = {}; + speedSub = SPEED_BASE; + spawnTimer = 0; // first obstacle spawns on the first step + spawnCount = 0; + resetClouds(); +} + +int32_t ChirpyRunnerGame::jumpScaleQ8() const +{ + // ratio = current scroll speed / base scroll speed, in Q8 (256 == 1.0). + const int32_t ratioQ8 = (speedSub * 256) / SPEED_BASE; + // Feed only JUMP_SPEEDUP_PCT of the speed increase into the jump scale: k = 1 + (ratio-1)*pct. + const int32_t kQ8 = 256 + ((ratioQ8 - 256) * JUMP_SPEEDUP_PCT) / 100; + return kQ8 < 256 ? 256 : kQ8; // never slower than the base hop +} + +void ChirpyRunnerGame::jump() +{ + if (!alive || !grounded) + return; + // Scale velocity by k and gravity by k*k: the apex height is unchanged (Chirpy still clears the + // same buildings) but the air-time shrinks by k, so the clearing window tightens with the speed. + const int32_t kQ8 = jumpScaleQ8(); + vy = -(JUMP_V * kQ8) / 256; + jumpGravity = (GRAVITY * kQ8 * kQ8) / (256 * 256); + if (jumpGravity < GRAVITY) + jumpGravity = GRAVITY; + grounded = false; +} + +bool ChirpyRunnerGame::step() +{ + if (!alive) + return false; + + scrollClouds(); // decorative background parallax + + // --- Chirpy vertical motion (gravity latched at launch, so the arc stays consistent mid-air) --- + vy += jumpGravity; + chirpyTop += vy; + const int32_t gt = groundedTopSub(); + if (chirpyTop >= gt) { + chirpyTop = gt; + vy = 0; + grounded = true; + } else { + grounded = false; + } + + // --- Scroll obstacles, score, retire off-screen ones --- + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) { + if (!obst[i].active) + continue; + obst[i].xSub -= speedSub; + const int16_t ox = obstacleX(i); + if (!obst[i].scored && ox + obst[i].w < CHIRPY_X) { + obst[i].scored = true; + points++; + } + if (ox + obst[i].w < 0) + obst[i].active = false; + } + + // --- Spawn on a tick timer (time-based spacing that scales with speed) --- + if (spawnTimer > 0) + spawnTimer--; + if (spawnTimer <= 0) { + spawnObstacle(); + spawnTimer = pickGapSteps(); + } + + // --- Difficulty ramp (scroll speed grows with score, then caps) --- + const uint32_t capped = points < SPEED_CAP_PTS ? points : SPEED_CAP_PTS; + speedSub = SPEED_BASE + static_cast(capped) * SPEED_INC; + + // --- Collision (forgiving hitbox: skip the antenna, inset the sides) --- + const int16_t hx = CHIRPY_X + 2; + const int16_t hxr = hx + (CHIRPY_W - 4); + const int16_t hBottom = chirpyY() + CHIRPY_H; + const int16_t hTop = chirpyY() + 4; + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) { + if (!obst[i].active) + continue; + const int16_t ox = obstacleX(i); + const int16_t oxr = ox + obst[i].w; + const int16_t oTop = GROUND_Y - obst[i].h; + if (hx < oxr && hxr > ox && hTop < GROUND_Y && hBottom > oTop) { + alive = false; + return false; + } + } + + return alive; +} + +// =========================================================================== +// ChirpyRunner adapter (display + persistence; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" + +ChirpyRunner::ChirpyRunner() +{ + scores_.load(); +} + +void ChirpyRunner::handleInput(input_broker_event ev) +{ + // SELECT is the jump (as requested); UP is accepted as a convenient alternate. + if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_SELECT_LONG || ev == INPUT_BROKER_UP) + game.jump(); +} + +void ChirpyRunner::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "CHIRPY DASH"); + const int16_t logoX = x + (w - chirpy_run_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, chirpy_run_width, chirpy_run_height, chirpy_run); +#if GRAPHICS_TFT_COLORING_ENABLED + // Chirpy is green, with white eyes. The eyes are the lit pixels at rows 5-7, cols 4-7 of the + // glyph; a white region registered after the green one wins there. + graphics::registerTFTColorRegionDirect(logoX, logoY, chirpy_run_width, chirpy_run_height, + graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg()); + graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg()); +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +#if GRAPHICS_TFT_COLORING_ENABLED +// Obstacle colour palette; the game logic advances the index every 10 spawns. +static uint16_t obstacleColor(uint8_t idx) +{ + using namespace graphics; + switch (idx) { + case 0: + return TFTPalette::Red; + case 1: + return TFTPalette::Orange; + case 2: + return TFTPalette::Yellow; + case 3: + return TFTPalette::Magenta; + case 4: + return TFTPalette::Cyan; + default: + return TFTPalette::Blue; + } +} +#endif + +void ChirpyRunner::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Clouds drifting in the background (drawn first so everything else sits in front). + for (uint8_t i = 0; i < ChirpyRunnerGame::cloudSlots(); i++) { + const int16_t cxp = x + game.cloudX(i); + const int16_t cyp = y + game.cloudY(i); + display->fillRect(cxp + 2, cyp, 4, 1); + display->fillRect(cxp + 1, cyp + 1, 6, 1); + display->fillRect(cxp, cyp + 2, 8, 1); +#if GRAPHICS_TFT_COLORING_ENABLED + graphics::registerTFTColorRegionDirect(cxp, cyp, 8, 3, graphics::TFTPalette::LightGray, graphics::getThemeBodyBg()); +#endif + } + + // Score (top-left). + char buf[16]; + display->setTextAlignment(TEXT_ALIGN_LEFT); + snprintf(buf, sizeof(buf), "Sc %lu", static_cast(game.score())); + display->drawString(x + 2, y, buf); + + // Ground line. + display->drawLine(x, y + ChirpyRunnerGame::GROUND_Y, x + display->getWidth() - 1, y + ChirpyRunnerGame::GROUND_Y); + + // Obstacles drawn as little buildings: a solid tower with two columns of punched-out windows + // (dark holes). On colour displays the walls cycle colour every 10 spawns and the windows glow + // (they are the region's off-pixels, so they take the off-colour). + for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++) { + if (!game.obstacleActive(i)) + continue; + const int16_t oh = game.obstacleH(i); + const int16_t ow = game.obstacleW(i); + const int16_t oxp = x + game.obstacleX(i); + const int16_t oyp = y + ChirpyRunnerGame::GROUND_Y - oh; + + display->setColor(WHITE); + display->fillRect(oxp, oyp, ow, oh); + // Windows: 1px holes in the left and right columns, every other row, skipping the roof row + // and the ground-floor rows so the tower reads as a building. + display->setColor(BLACK); + for (int16_t wy = oyp + 2; wy <= oyp + oh - 3; wy += 2) { + display->fillRect(oxp + 1, wy, 1, 1); + display->fillRect(oxp + ow - 2, wy, 1, 1); + } + display->setColor(WHITE); +#if GRAPHICS_TFT_COLORING_ENABLED + graphics::registerTFTColorRegionDirect(oxp, oyp, ow, oh, obstacleColor(game.obstacleColorIndex(i)), + graphics::TFTPalette::White); // lit windows +#endif + } + + // Chirpy. + const int16_t cxp = x + ChirpyRunnerGame::CHIRPY_X; + const int16_t cyp = y + game.chirpyY(); + display->drawXbm(cxp, cyp, chirpy_run_width, chirpy_run_height, chirpy_run); +#if GRAPHICS_TFT_COLORING_ENABLED + graphics::registerTFTColorRegionDirect(cxp, cyp, chirpy_run_width, chirpy_run_height, graphics::TFTPalette::MeshtasticGreen, + graphics::getThemeBodyBg()); + graphics::registerTFTColorRegionDirect(cxp + 4, cyp + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg()); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/ChirpyRunner.h b/src/modules/games/ChirpyRunner.h new file mode 100644 index 000000000..b86e1d775 --- /dev/null +++ b/src/modules/games/ChirpyRunner.h @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include + +/** + * Pure, self-contained Chirpy Runner game logic (a dino-runner / flappy-style side-scroller). + * + * No Arduino/display dependencies - designed to be unit-tested natively (see test/test_chirpy) + * and reused by the ChirpyRunner adapter below without pulling in the display stack. + * + * Chirpy runs in place at a fixed x on the ground; obstacles scroll in from the right and the + * player presses jump (SELECT) to hop over them. Gravity pulls Chirpy back down. Clearing an + * obstacle scores a point and nudges the scroll speed up. A collision ends the run. Chirpy's + * vertical motion is tracked in fixed-point sub-pixels (SUBPX per pixel); everything is integer + * math and statically sized (no heap). + */ +class ChirpyRunnerGame +{ + public: + // Playfield, in pixels (a standard 128x64 OLED in landscape). + static constexpr int16_t BOARD_W = 128; + static constexpr int16_t BOARD_H = 64; + + // Chirpy sprite box and fixed horizontal position; GROUND_Y is where his feet rest. + static constexpr int16_t CHIRPY_W = 12; + static constexpr int16_t CHIRPY_H = 16; + static constexpr int16_t CHIRPY_X = 14; + static constexpr int16_t GROUND_Y = BOARD_H - 2; // 62 + + static constexpr uint8_t MAX_OBSTACLES = 4; + static constexpr int16_t OBST_W = 6; + static constexpr uint8_t OBST_COLOR_COUNT = 6; // obstacle colour advances every 10 spawns + static constexpr uint8_t CLOUD_COUNT = 3; // decorative background clouds + + /** (Re)start a run: Chirpy on the ground, no obstacles yet, score 0. `seed` drives the + * xorshift32 RNG used for obstacle heights and spacing. */ + void reset(uint32_t seed); + + /** Advance one tick (gravity + scroll + spawn + collision). Returns true while the run is in + * progress, false once Chirpy hits an obstacle. No-op returning false once dead. */ + bool step(); + + /** Hop, if Chirpy is on the ground (single jump; ignored while airborne). */ + void jump(); + + bool isPlaying() const { return alive; } + uint32_t score() const { return points; } + bool onGround() const { return grounded; } + + // --- Rendering accessors (board pixels) --- + int16_t chirpyY() const { return static_cast(chirpyTop / SUBPX); } // sprite top + static constexpr uint8_t obstacleSlots() { return MAX_OBSTACLES; } + bool obstacleActive(uint8_t i) const { return i < MAX_OBSTACLES && obst[i].active; } + int16_t obstacleX(uint8_t i) const { return static_cast(obst[i].xSub / SUBPX); } + uint8_t obstacleW(uint8_t i) const { return obst[i].w; } + uint8_t obstacleH(uint8_t i) const { return obst[i].h; } + uint8_t obstacleColorIndex(uint8_t i) const { return obst[i].colorIdx; } + static constexpr uint8_t cloudSlots() { return CLOUD_COUNT; } + int16_t cloudX(uint8_t i) const { return static_cast(cloud[i].xSub / SUBPX); } + int16_t cloudY(uint8_t i) const { return cloud[i].y; } + + private: + static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel + static constexpr int32_t GRAVITY = 7; // downward accel (sub-px/step^2) + static constexpr int32_t JUMP_V = 75; // initial upward velocity on a hop (sub-px/step) + static constexpr int32_t SPEED_BASE = 32; // base scroll speed (sub-px/step == 2 px) + static constexpr int32_t SPEED_INC = 2; // speed added per point scored + static constexpr uint32_t SPEED_CAP_PTS = 40; // score at which the speed ramp caps (== 5 px/step) + // Obstacle spacing is measured in TICKS, not pixels, so the time between obstacles stays + // constant as the scroll speed ramps up -- otherwise a fixed pixel gap collapses into fewer + // ticks at high speed until it drops below the jump duration and clearing becomes impossible. + // The min is kept just above the ~22-tick jump so obstacles come in a tight but landable rhythm. + static constexpr int16_t GAP_STEPS_MIN = 23; // min ticks between spawns (> jump duration) + static constexpr int16_t GAP_STEPS_MAX = 40; // max ticks between spawns + + // Chirpy's hop scales with the scroll speed. A fixed-length jump actually makes the game EASIER + // as the buildings speed up: his "high enough to clear" window stays ~constant while the time a + // building spends crossing his x-range shrinks (width / speed). Scaling the launch velocity by k + // and gravity by k*k keeps the apex height identical (so he still clears the same buildings) + // while cutting the air-time by k, which shrinks the clearing window in step with the world. + // JUMP_SPEEDUP_PCT is how much of the scroll-speed increase feeds into k: + // 100 == fully proportional (difficulty stays flat), lower == Chirpy speeds up sub-linearly + // (so it still eases off slightly at high speed), higher == it gets harder. + static constexpr int32_t JUMP_SPEEDUP_PCT = 50; + + static constexpr int8_t BUILDING_HEIGHTS[] = {8, 12, 16}; // three tiers of obstacle heights (pixels) + + static constexpr int32_t CLOUD_SPEED_SUB = 6; // background scroll speed (sub-px/step, slow parallax) + static constexpr int16_t CLOUD_W = 8; // cloud puff width (for off-screen wrap) + + struct Obstacle { + int32_t xSub; // left edge, sub-pixels + uint8_t w; + uint8_t h; + uint8_t colorIdx; // which colour batch (advances every 10 spawns) + bool active; + bool scored; + }; + + struct Cloud { + int32_t xSub; // left edge, sub-pixels + int16_t y; // top, pixels + }; + + int32_t groundedTopSub() const { return static_cast(GROUND_Y - CHIRPY_H) * SUBPX; } + // Jump scale factor k for the current scroll speed, in Q8 fixed point (256 == 1.0 == base speed). + int32_t jumpScaleQ8() const; + uint32_t nextRandom(); + void spawnObstacle(); + int16_t pickGapSteps(); + void resetClouds(); + void scrollClouds(); + + int32_t chirpyTop = 0; // sprite-top y, sub-pixels + int32_t vy = 0; // vertical velocity, sub-pixels/step + int32_t jumpGravity = GRAVITY; // gravity for the current hop (latched at launch, scaled by k*k) + bool grounded = true; + + Obstacle obst[MAX_OBSTACLES] = {}; + Cloud cloud[CLOUD_COUNT] = {}; + int32_t speedSub = SPEED_BASE; + int16_t spawnTimer = 0; // ticks until the next obstacle spawns + uint32_t spawnCount = 0; // total obstacles spawned (drives the colour cycle) + + uint32_t points = 0; + uint32_t rng = 1; // xorshift32 state (never 0) + bool alive = false; +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Chirpy Runner as a hosted Game. Wraps the pure logic above and supplies the attract art, the + * side-scroller renderer (Chirpy sprite + obstacles + ground), the jump input, and its own + * local high-score table. No mesh protocol (scores are local-only). + */ +class ChirpyRunner : public Game +{ + public: + ChirpyRunner(); + + const char *name() const override { return "Chirpy Dash"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override { return game.step(); } + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override { return 33; } // ~30 fps; difficulty ramps via scroll speed + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record. New file (magic 'CHRP', version 1); layout mirrors Snake's. + struct ChirpyEntry { + uint32_t score; + uint32_t nodeNum; + char shortName[5]; // three characters, NUL-terminated + uint32_t epoch; // getValidTime(), 0 if no RTC + } __attribute__((packed)); + + ChirpyRunnerGame game; + HighScoreTable scores_{"/prefs/chirpy.dat", 0x43485250u, 1, "Chirpy"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Game.h b/src/modules/games/Game.h new file mode 100644 index 000000000..751b2d6b5 --- /dev/null +++ b/src/modules/games/Game.h @@ -0,0 +1,57 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "HighScoreTable.h" +#include "input/InputBroker.h" // input_broker_event +#include "mesh/MeshModule.h" // ProcessMessage, meshtastic_MeshPacket +#include + +class OLEDDisplay; +class OLEDDisplayUiState; +class GamesModule; + +/** + * A single game hosted by GamesModule. The host owns the shared UI state machine (attract / + * playing / paused / game-over / high-scores), the initials picker, high-score persistence calls, + * the tick thread, and the mesh port; each Game supplies only the game-specific pieces: its + * attract art, its playfield, its per-key input while playing, its speed curve, and its own + * high-score table (and, optionally, a mesh announce/receive protocol). + */ +class Game +{ + public: + virtual ~Game() = default; + + virtual const char *name() const = 0; + + // --- Lifecycle --- + virtual void start(uint32_t seed) = 0; // (re)start the underlying game logic + virtual bool tick() = 0; // advance one step; returns isPlaying() afterwards + virtual bool isPlaying() const = 0; + virtual uint32_t score() const = 0; + virtual int32_t tickIntervalMs() const = 0; // per-game speed curve + + // --- Input while PLAYING (the host handles the BACK-to-pause and menu keys) --- + virtual void handleInput(input_broker_event ev) = 0; + + // --- Rendering (the host draws the shared PAUSED / GAME OVER / HIGH SCORES chrome) --- + virtual void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) = 0; // title/art + hi + hint + virtual void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) = 0; // playfield only + virtual const char *gameOverHint() const { return "SELECT: scores"; } + + // --- High scores (the host runs the initials picker + save) --- + virtual HighScoreTableBase &scores() = 0; + + // --- Mesh (defaults are no-ops; only games with a wire protocol override these) --- + // Note: the new-high-score announcement is NOT here -- it is shared by every game and lives in + // GamesModule (which splices name() into one common message). + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) { return ProcessMessage::CONTINUE; } + virtual bool wantsPeriodicMesh() const { return false; } + // Perform any due periodic broadcast and return ms until the next one (-1 == nothing pending). + virtual int32_t meshTick(GamesModule &host) { return -1; } +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp new file mode 100644 index 000000000..1d72ba632 --- /dev/null +++ b/src/modules/games/GamesModule.cpp @@ -0,0 +1,373 @@ +#include "GamesModule.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Breakout.h" +#include "ChirpyRunner.h" +#include "PowerFSM.h" +#include "Snake.h" +#include "Tetris.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "main.h" +#include "mesh/NodeDB.h" +#if GAMES_ANNOUNCE_HIGH_SCORE +#include "MeshService.h" +#endif + +GamesModule *gamesModule; + +GamesModule::GamesModule() : SinglePortModule("games", meshtastic_PortNum_PRIVATE_APP), concurrency::OSThread("Games") +{ + // Register the hosted games. Order sets the attract-screen cycle order (Snake is shown first). + games.push_back(new Snake()); + games.push_back(new Tetris()); + games.push_back(new ChirpyRunner()); + games.push_back(new Breakout()); + inputObserver.observe(inputBroker); + + // Keep the tick thread alive at boot only if a game broadcasts periodically; otherwise idle + // until the player launches a game. The first idle tick reschedules to the real cadence. + bool periodic = false; + for (Game *g : games) + periodic = periodic || g->wantsPeriodicMesh(); + if (periodic) + setIntervalFromNow(1000); + else + disable(); +} + +// --------------------------------------------------------------------------- +// Lifecycle / state transitions +// --------------------------------------------------------------------------- + +void GamesModule::launchGame() +{ + if (games.empty()) + return; + // The games frame is already current (the player is on the attract screen), so just begin play + // with the selected game -- no focus change or frameset regeneration needed. + active = games[selected]; + startPlaying(); +} + +void GamesModule::startPlaying() +{ + active->start(static_cast(random()) ^ millis()); + uiState = GAMES_PLAYING; + lastAwakeKickMs = millis(); + kickTick(); + requestRedraw(); +} + +void GamesModule::enterGameOver() +{ + lastScore = active ? active->score() : 0; + lastRank = -1; + lastWasNewTop = false; + uiState = GAMES_GAMEOVER; + + // Arcade-style: if the score placed, prompt for initials, then record it in the picker's + // callback. Otherwise just show the game-over screen. + if (active && active->scores().qualifies(lastScore)) + promptForInitials(); + + requestRedraw(); +} + +void GamesModule::promptForInitials() +{ + screen->showAlphanumericPicker("New High Score!\nEnter initials", "AAA", 60000, HighScoreTableBase::INITIALS_LEN, + [this](const std::string &initials) { this->recordHighScore(initials.c_str()); }); +} + +void GamesModule::recordHighScore(const char *initials) +{ + if (!active) + return; + bool isNewTop = false; + lastRank = active->scores().insert(lastScore, initials, nodeDB ? nodeDB->getNodeNum() : 0, isNewTop); + lastWasNewTop = isNewTop; + if (lastRank >= 0) + active->scores().save(); // table changed -- the only time we write flash +#if GAMES_ANNOUNCE_HIGH_SCORE + if (isNewTop && lastScore > 0) + announceHighScore(initials, lastScore); +#endif + requestRedraw(); +} + +#if GAMES_ANNOUNCE_HIGH_SCORE +void GamesModule::announceHighScore(const char *initials, uint32_t score) +{ + if (!active || !service) + return; + if (!initials || initials[0] == '\0') + return; + meshtastic_MeshPacket *p = allocDataPacket(); + p->to = NODENUM_BROADCAST; + p->channel = 0; // primary channel + p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + // One shared message for every game, with the game's name spliced in. ASCII only -- avoids tofu + // if a receiving node's font lacks a glyph. + p->decoded.payload.size = snprintf(reinterpret_cast(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes), + GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast(score)); + service->sendToMesh(p); + LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast(score)); +} +#endif + +void GamesModule::exitToIdle() +{ + uiState = GAMES_IDLE; + active = nullptr; + // The games frame is always present, so we just return it to the attract screen and redraw -- + // no frameset change. interceptingKeyboardInput() now returns false, so the D-pad navigates + // between frames again. Keep ticking only if a game still needs its periodic broadcast. + bool periodic = false; + for (Game *g : games) + periodic = periodic || g->wantsPeriodicMesh(); + if (periodic) + setIntervalFromNow(1000); + else + disable(); + requestRedraw(); +} + +void GamesModule::requestRedraw() +{ + UIFrameEvent e; + e.action = UIFrameEvent::Action::REDRAW_ONLY; + notifyObservers(&e); +} + +void GamesModule::kickTick() +{ + enabled = true; + setIntervalFromNow(250); // brief beat so the player sees the board before it moves +} + +int32_t GamesModule::runOnce() +{ + if (uiState == GAMES_PLAYING && active) { + if (!active->tick()) { + enterGameOver(); + return disable(); + } + + // Keep the display awake through long runs that generate no key presses. + const uint32_t now = millis(); + if (now - lastAwakeKickMs > 1500) { + powerFSM.trigger(EVENT_PRESS); + lastAwakeKickMs = now; + } + + requestRedraw(); + return active->tickIntervalMs(); + } + + // Idle: service any game that broadcasts periodically; sleep until the soonest one is due. + int32_t next = -1; + for (Game *g : games) { + const int32_t due = g->meshTick(*this); + if (due >= 0 && (next < 0 || due < next)) + next = due; + } + return next < 0 ? disable() : next; +} + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +int GamesModule::handleInputEvent(const InputEvent *event) +{ + // Ignore all input unless the games frame is the one actually on screen -- otherwise the attract + // screen's UP/DOWN would hijack normal frame navigation from wherever the player happens to be. + if (!screen || !screen->isGamesFrameShown()) + return 0; + if (screen->isOverlayBannerShowing()) + return 0; // a menu banner is up; don't steal its input + + const input_broker_event ev = event->inputEvent; + const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK); + + switch (uiState) { + case GAMES_IDLE: + // Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it; + // long-press SELECT opens that game's high-score table. Everything else passes through so + // the D-pad still navigates between frames. + if (!games.empty() && (ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_UP)) { + const uint8_t n = static_cast(games.size()); + selected = (ev == INPUT_BROKER_DOWN) ? (selected + 1) % n : (selected + n - 1) % n; + requestRedraw(); + return 1; + } + if (ev == INPUT_BROKER_SELECT_LONG && !games.empty()) { + active = games[selected]; + uiState = GAMES_HISCORES; + requestRedraw(); + return 1; + } + return 0; + + case GAMES_PLAYING: + if (isBack) { + uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit + disable(); + requestRedraw(); + } else if (active) { + active->handleInput(ev); + if (!active->isPlaying()) { + enterGameOver(); + return 1; + } + requestRedraw(); + } + return 1; + + case GAMES_PAUSED: + if (isBack) { + exitToIdle(); // quit from pause + } else if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_UP || ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_LEFT || + ev == INPUT_BROKER_RIGHT) { + uiState = GAMES_PLAYING; + kickTick(); + requestRedraw(); + } + return 1; + + case GAMES_GAMEOVER: + if (ev == INPUT_BROKER_SELECT) { + uiState = GAMES_HISCORES; + requestRedraw(); + } else if (isBack) { + exitToIdle(); + } + return 1; + + case GAMES_HISCORES: + if (ev == INPUT_BROKER_SELECT_LONG && active) { + static const char *opts[] = {"No", "Yes"}; + graphics::BannerOverlayOptions confirm; + confirm.message = "Clear Scores?"; + confirm.optionsArrayPtr = opts; + confirm.optionsCount = 2; + confirm.bannerCallback = [this](int sel) { + if (sel == 1 && active) { + active->scores().clear(); + active->scores().save(); + requestRedraw(); + LOG_INFO("Games: high scores cleared"); + } + }; + if (screen) + screen->showOverlayBanner(confirm); + } else if (ev == INPUT_BROKER_SELECT || isBack) { + exitToIdle(); + } + return 1; + + default: + return 0; + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +void GamesModule::drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count) +{ + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + const int16_t lineH = FONT_HEIGHT_SMALL; + const int16_t total = static_cast(count) * lineH; + int16_t startY = y + (display->getHeight() - total) / 2; + if (startY < y) + startY = y; + const int16_t cx = x + display->getWidth() / 2; + for (uint8_t i = 0; i < count; i++) + display->drawString(cx, startY + i * lineH, lines[i]); +} + +void GamesModule::drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores) +{ + display->setFont(FONT_SMALL); + display->setColor(WHITE); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(x, y, "HIGH SCORES"); + display->setTextAlignment(TEXT_ALIGN_LEFT); + const int16_t rowH = (display->getHeight() - FONT_HEIGHT_SMALL) / HighScoreTableBase::HS_COUNT; + for (uint8_t i = 0; i < HighScoreTableBase::HS_COUNT; i++) { + char row[32]; + if (scores.scoreAt(i) > 0) { + snprintf(row, sizeof(row), "%u. %-4s %lu", static_cast(i + 1), scores.nameAt(i), + static_cast(scores.scoreAt(i))); + } else { + snprintf(row, sizeof(row), "%u. ---", static_cast(i + 1)); + } + display->drawString(x + 6, y + FONT_HEIGHT_SMALL + i * rowH, row); + } +} + +void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/, int16_t x, int16_t y) +{ + display->setColor(WHITE); + + switch (uiState) { + case GAMES_IDLE: + if (!games.empty()) + games[selected]->drawAttract(display, x, y); + break; + + case GAMES_PLAYING: + if (active) + active->drawPlaying(display, x, y); + break; + + case GAMES_PAUSED: + if (active) { + active->drawPlaying(display, x, y); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + display->getWidth() / 2, y + display->getHeight() / 2 - FONT_HEIGHT_SMALL / 2, "- PAUSED -"); + } + break; + + case GAMES_GAMEOVER: { + char scoreLine[24]; + snprintf(scoreLine, sizeof(scoreLine), "Score: %lu", static_cast(lastScore)); + const char *status = lastWasNewTop ? "NEW HIGH SCORE!" : (lastRank >= 0 ? "You made the top 5!" : ""); + const char *hint = active ? active->gameOverHint() : "SELECT: scores"; + const char *lines[] = {"GAME OVER", scoreLine, status, hint}; + drawCenteredLines(display, x, y, lines, 4); + break; + } + + case GAMES_HISCORES: + if (active) + drawHighScores(display, x, y, active->scores()); + break; + + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Mesh receive - dispatch to each hosted game +// --------------------------------------------------------------------------- + +ProcessMessage GamesModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + for (Game *g : games) { + const ProcessMessage r = g->handleReceived(mp); + if (r != ProcessMessage::CONTINUE) + return r; + } + requestRedraw(); // a merged remote score should show up if the high-score screen is open + return ProcessMessage::CONTINUE; +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/GamesModule.h b/src/modules/games/GamesModule.h new file mode 100644 index 000000000..eb4642f19 --- /dev/null +++ b/src/modules/games/GamesModule.h @@ -0,0 +1,101 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "Observer.h" +#include "concurrency/OSThread.h" +#include "input/InputBroker.h" +#include "mesh/SinglePortModule.h" +#include + +// Broadcasting a new all-time #1 to the mesh is a COMPILE-TIME option, default OFF. Spending shared +// airtime must be opted into at build time with -DGAMES_ANNOUNCE_HIGH_SCORE=1; when disabled (the +// default) the announcement code is compiled out entirely -- there is no runtime toggle. The +// announcement is shared by every hosted game, with the game's name() spliced into the message. +#ifndef GAMES_ANNOUNCE_HIGH_SCORE +#define GAMES_ANNOUNCE_HIGH_SCORE 0 +#endif + +#ifndef GAMES_HIGH_SCORE_STRING +#define GAMES_HIGH_SCORE_STRING "New %s high score %lu by %s!" +#endif + +enum GamesUiState : uint8_t { + GAMES_IDLE, // attract screen of the selected game; OSThread idle (unless a game broadcasts) + GAMES_PLAYING, // active game running; tick thread ticking + GAMES_PAUSED, // paused mid-game + GAMES_GAMEOVER, // final score / new-high notice + GAMES_HISCORES, // top-5 table of the active/selected game +}; + +/** + * The single host for all BaseUI games. It owns the always-present "games" frame (drawn through + * Screen's trampoline right after home), the shared UI state machine, the game-tick OSThread, the + * initials picker + high-score persistence flow, and the PRIVATE_APP mesh port. Individual games + * are self-contained Game subclasses registered in the constructor (see src/modules/games/); the + * attract screen cycles between them with UP/DOWN and SELECT plays the shown game. + */ +class GamesModule : public SinglePortModule, public Observable, private concurrency::OSThread +{ + public: + GamesModule(); + + /// Start the currently-selected game (invoked when SELECT is pressed on the games frame). The + /// games frame is already current, so this just begins play. + void launchGame(); + + // Drawn through the games-frame trampoline, and queried by Screen's input gating / nav-bar, so + // these are public. While a game is active we own the D-pad; on the attract screen the D-pad + // cycles games (UP/DOWN) and otherwise navigates between frames as usual. + void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + virtual bool interceptingKeyboardInput() override { return uiState != GAMES_IDLE; } + + /// Mesh passthrough for hosted games (a Game is not itself a MeshModule). + meshtastic_MeshPacket *gameAllocDataPacket() { return allocDataPacket(); } + + protected: + virtual int32_t runOnce() override; // game tick + idle mesh scheduling + virtual Observable *getUIFrameObservable() override { return this; } + virtual bool wantUIFrame() override { return false; } // shares the games frame; no own slot + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + private: + int handleInputEvent(const InputEvent *event); + CallbackObserver inputObserver = + CallbackObserver(this, &GamesModule::handleInputEvent); + + // === State transitions === + void startPlaying(); + void enterGameOver(); + void exitToIdle(); + void requestRedraw(); + void kickTick(); + + // === Shared game-over / high-score flow === + void promptForInitials(); + void recordHighScore(const char *initials); +#if GAMES_ANNOUNCE_HIGH_SCORE + // Broadcast a new all-time #1 as a plain text message, shared by every game (name() spliced in). + void announceHighScore(const char *initials, uint32_t score); +#endif + + // === Shared rendering === + void drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count); + void drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores); + + std::vector games; + uint8_t selected = 0; // attract-screen cursor (index into games) + Game *active = nullptr; // game currently playing / whose scores are shown; null when idle + GamesUiState uiState = GAMES_IDLE; + uint32_t lastScore = 0; // score of the just-finished game (for the GAME OVER screen) + int lastRank = -1; // rank achieved last game (-1 == didn't place) + bool lastWasNewTop = false; // last game set a new all-time #1 + uint32_t lastAwakeKickMs = 0; // throttles the power-FSM wake nudge during long runs +}; + +extern GamesModule *gamesModule; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/HighScoreTable.h b/src/modules/games/HighScoreTable.h new file mode 100644 index 000000000..b833ed05a --- /dev/null +++ b/src/modules/games/HighScoreTable.h @@ -0,0 +1,176 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "DebugConfiguration.h" +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "concurrency/LockGuard.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" // owner (short-name fallback) +#include +#include +#include +#include + +/** + * Non-templated view of a game's top-N high-score table, so the shared games UI (attract line, + * high-score screen) and the Game interface can talk to any game's table without knowing its + * on-disk record layout. + */ +class HighScoreTableBase +{ + public: + static constexpr uint8_t HS_COUNT = 5; // entries kept per game + static constexpr uint8_t INITIALS_LEN = 3; // arcade-style initials captured per high score + + virtual ~HighScoreTableBase() = default; + + virtual uint32_t scoreAt(uint8_t i) const = 0; + virtual const char *nameAt(uint8_t i) const = 0; + + // True if `score` would place on the sorted-descending table (peek; no mutation). + virtual bool qualifies(uint32_t score) const = 0; + // Insert under `initials` with the given `nodeNum` (0 == local, or a foreign node for merged + // remote scores). Returns the 0-based rank if it placed, else -1; isNewTop set on the #1 slot. + virtual int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) = 0; + virtual void clear() = 0; + + virtual void load() = 0; + virtual void save() = 0; + virtual bool loaded() const = 0; +}; + +/** + * Templated top-N table shared by every game. The algorithm (qualify / insert / load / save / CRC) + * is identical across games; only the on-disk record layout differs, so `Entry` is a template + * parameter. Each game supplies its own packed `Entry` (with `score`, `shortName[5]`, `nodeNum`, + * `epoch` fields, in whatever byte order its existing save file used) plus a file path, magic, and + * version -- so pre-existing save files keep loading byte-for-byte after the refactor. + */ +template class HighScoreTable : public HighScoreTableBase +{ + public: + HighScoreTable(const char *path, uint32_t magic, uint8_t version, const char *logTag) + : path_(path), magic_(magic), version_(version), logTag_(logTag) + { + } + + uint32_t scoreAt(uint8_t i) const override { return entries_[i].score; } + const char *nameAt(uint8_t i) const override { return entries_[i].shortName; } + const Entry &entryAt(uint8_t i) const { return entries_[i]; } + + bool qualifies(uint32_t score) const override + { + if (score == 0) + return false; + for (int i = 0; i < HS_COUNT; i++) { + if (score > entries_[i].score) + return true; + } + return false; + } + + int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) override + { + isNewTop = false; + if (score == 0) + return -1; + + int pos = -1; + for (int i = 0; i < HS_COUNT; i++) { + if (score > entries_[i].score) { + pos = i; + break; + } + } + if (pos < 0) + return -1; // not good enough to place + + for (int i = HS_COUNT - 1; i > pos; i--) + entries_[i] = entries_[i - 1]; + + Entry &e = entries_[pos]; + memset(&e, 0, sizeof(e)); + e.score = score; + e.nodeNum = nodeNum; + strncpy(e.shortName, (initials && initials[0]) ? initials : owner.short_name, sizeof(e.shortName) - 1); + e.shortName[sizeof(e.shortName) - 1] = '\0'; + e.epoch = getValidTime(RTCQualityDevice, false); // 0 when no valid RTC + + isNewTop = (pos == 0); + return pos; + } + + void clear() override { memset(entries_, 0, sizeof(entries_)); } + bool loaded() const override { return loaded_; } + + void load() override + { + loaded_ = true; + memset(entries_, 0, sizeof(entries_)); +#ifdef FSCom + concurrency::LockGuard g(spiLock); + auto f = FSCom.open(path_, FILE_O_READ); + if (!f) + return; + File file; + const bool readOk = (f.read(reinterpret_cast(&file), sizeof(file)) == sizeof(file)); + f.close(); + if (!readOk || file.magic != magic_ || file.version != version_) { + LOG_DEBUG("%s: no valid high-score file, starting fresh", logTag_); + return; + } + if (crc32Buffer(&file, offsetof(File, crc)) != file.crc) { + LOG_WARN("%s: high-score CRC mismatch, resetting table", logTag_); + return; + } + memcpy(entries_, file.entries, sizeof(entries_)); + LOG_INFO("%s: loaded high scores (top=%lu)", logTag_, static_cast(entries_[0].score)); +#endif + } + + void save() override + { +#ifdef FSCom + { + concurrency::LockGuard g(spiLock); + FSCom.mkdir("/prefs"); + } + File file; + memset(&file, 0, sizeof(file)); + file.magic = magic_; + file.version = version_; + memcpy(file.entries, entries_, sizeof(entries_)); + file.crc = crc32Buffer(&file, offsetof(File, crc)); + + auto sf = SafeFile(path_, true); + const size_t written = sf.write(reinterpret_cast(&file), sizeof(file)); + if (!sf.close() || written != sizeof(file)) + LOG_WARN("%s: failed to save high scores", logTag_); +#endif + } + + private: + // On-disk layout. The 8-byte header (magic + version + 3 reserved) matches both the original + // Snake and Tetris files byte-for-byte, so their save files still load unchanged. + struct File { + uint32_t magic; + uint8_t version; + uint8_t reserved[3]; + Entry entries[HighScoreTableBase::HS_COUNT]; + uint32_t crc; // crc32 over every preceding byte + } __attribute__((packed)); + + Entry entries_[HS_COUNT] = {}; + const char *path_; + uint32_t magic_; + uint8_t version_; + const char *logTag_; + bool loaded_ = false; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Snake.cpp b/src/modules/games/Snake.cpp new file mode 100644 index 000000000..13dc616f1 --- /dev/null +++ b/src/modules/games/Snake.cpp @@ -0,0 +1,264 @@ +#include "Snake.h" + +// =========================================================================== +// Pure SnakeGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +void SnakeGame::reset(uint32_t seed) +{ + memset(occ, 0, sizeof(occ)); + len = 0; + points = 0; + alive = true; + won = false; + rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0 + + // Spawn a START_LEN snake horizontally in the middle of the board, heading right, with the + // head at the centre and the body trailing to its left. + const uint8_t cx = GRID_W / 2; + const uint8_t cy = GRID_H / 2; + dir = DIR_RIGHT; + pendingDir = DIR_RIGHT; + tailIdx = 0; + for (uint8_t i = 0; i < START_LEN; i++) { + const uint8_t x = static_cast(cx - (START_LEN - 1) + i); + body[i] = {x, cy}; + setOcc(cellIndex(x, cy)); + len++; + } + headIdx = static_cast(START_LEN - 1); + + placeFood(); +} + +bool SnakeGame::isReverse(Direction a, Direction b) +{ + return (a == DIR_UP && b == DIR_DOWN) || (a == DIR_DOWN && b == DIR_UP) || (a == DIR_LEFT && b == DIR_RIGHT) || + (a == DIR_RIGHT && b == DIR_LEFT); +} + +bool SnakeGame::setDirection(Direction d) +{ + if (isReverse(dir, d)) + return false; + pendingDir = d; + return true; +} + +uint32_t SnakeGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +bool SnakeGame::placeFood() +{ + const uint16_t free = static_cast(CELL_COUNT - len); + if (free == 0) + return false; // board full -> caller treats as a win + + // Pick the k-th free cell (k uniform in [0, free)) via a single linear scan. Deterministic, + // always valid, and cheap for a 384-cell board -- no rejection sampling / near-full special case. + uint16_t k = static_cast(nextRandom() % free); + for (uint16_t idx = 0; idx < CELL_COUNT; idx++) { + if (!getOcc(idx)) { + if (k == 0) { + foodCell = {static_cast(idx % GRID_W), static_cast(idx / GRID_W)}; + return true; + } + k--; + } + } + return false; // unreachable while free > 0 +} + +bool SnakeGame::step() +{ + if (!alive) + return false; + + dir = pendingDir; // commit the latched heading + + const Cell h = body[headIdx]; + int16_t nx = h.x; + int16_t ny = h.y; + switch (dir) { + case DIR_UP: + ny--; + break; + case DIR_DOWN: + ny++; + break; + case DIR_LEFT: + nx--; + break; + case DIR_RIGHT: + nx++; + break; + } + + // Wall collision (signed math catches the 0-- underflow too). + if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) { + alive = false; + return false; + } + + const uint16_t nidx = cellIndex(static_cast(nx), static_cast(ny)); + const bool eating = (nx == foodCell.x && ny == foodCell.y); + + // When not eating the tail vacates this tick, so free it first: moving into the cell the + // tail is leaving is legal (classic snake), moving into any other body cell is fatal. + if (!eating) { + clearOcc(cellIndex(body[tailIdx].x, body[tailIdx].y)); + tailIdx = static_cast((tailIdx + 1) % CAP); + len--; + } + + if (getOcc(nidx)) { + alive = false; + return false; + } + + // Advance the head into the new cell. + headIdx = static_cast((headIdx + 1) % CAP); + body[headIdx] = {static_cast(nx), static_cast(ny)}; + setOcc(nidx); + len++; + + if (eating) { + points++; + if (!placeFood()) { + won = true; + alive = false; // board completely filled -- the player won + } + } + + return alive; +} + +// =========================================================================== +// Snake adapter (display + persistence; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" + +// --- Pixel layout on a 128x64 OLED ----------------------------------------------------------- +// Each cell is CELL_PX square. The GRID_W x GRID_H playfield (128x48) is bottom-aligned, leaving +// a SCORE_H-pixel score bar across the top. On taller displays the board bottom-aligns and +// centres horizontally; screen edges are the walls. +static constexpr int16_t CELL_PX = 4; +static constexpr int16_t SCORE_H = 16; + +Snake::Snake() +{ + scores_.load(); +} + +int32_t Snake::tickIntervalMs() const +{ + // Speed ramps up as the snake grows: 160 ms base, down to a 70 ms floor. + int32_t iv = 160 - static_cast(game.length()) * 3; + return iv < 70 ? 70 : iv; +} + +void Snake::handleInput(input_broker_event ev) +{ + switch (ev) { + case INPUT_BROKER_UP: + game.setDirection(SnakeGame::DIR_UP); + break; + case INPUT_BROKER_DOWN: + game.setDirection(SnakeGame::DIR_DOWN); + break; + case INPUT_BROKER_LEFT: + game.setDirection(SnakeGame::DIR_LEFT); + break; + case INPUT_BROKER_RIGHT: + game.setDirection(SnakeGame::DIR_RIGHT); + break; + default: + break; + } +} + +void Snake::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "S N A K E"); + // Snake glyph, centred below the title. + const int16_t logoX = x + (w - snake_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, snake_width, snake_height, snake); +#if GRAPHICS_TFT_COLORING_ENABLED + // On a colour display, paint the snake green with a red tongue. The forked tongue is the pair + // of pixels poking out past the body on the right edge (~row 6 of the 16x16 glyph); a red + // region registered after the green one wins where they overlap, so the body stays green. + const uint16_t bg = graphics::getThemeBodyBg(); + graphics::registerTFTColorRegionDirect(logoX, logoY, snake_width, snake_height, graphics::TFTPalette::Green, bg); + graphics::registerTFTColorRegionDirect(logoX + 13, logoY + 5, 3, 4, graphics::TFTPalette::Red, bg); +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +void Snake::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + char buf[24]; + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Score bar: current score on the left, best on the right. + display->setTextAlignment(TEXT_ALIGN_LEFT); + snprintf(buf, sizeof(buf), "Score %lu", static_cast(game.score())); + display->drawString(x + 2, y + 2, buf); + if (scores_.scoreAt(0) > 0) { + display->setTextAlignment(TEXT_ALIGN_RIGHT); + snprintf(buf, sizeof(buf), "Hi %lu", static_cast(scores_.scoreAt(0))); + display->drawString(x + display->getWidth() - 2, y + 2, buf); + } + display->drawLine(x, y + SCORE_H - 1, x + display->getWidth() - 1, y + SCORE_H - 1); + + // Board is bottom-aligned; centre it horizontally. + const int16_t boardW = SnakeGame::GRID_W * CELL_PX; + const int16_t boardH = SnakeGame::GRID_H * CELL_PX; + const int16_t ox = x + (display->getWidth() - boardW) / 2; + const int16_t oy = y + display->getHeight() - boardH; + + for (uint16_t i = 0; i < game.length(); i++) { + SnakeGame::Cell c = game.bodyAt(i); + display->fillRect(ox + c.x * CELL_PX, oy + c.y * CELL_PX, CELL_PX, CELL_PX); + } + // Food: a 2x2 dot centred in its cell. + SnakeGame::Cell f = game.food(); + display->fillRect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2); + +#if GRAPHICS_TFT_COLORING_ENABLED + // On a colour display, paint the whole snake green, then the apple red on top. One region over + // the board tints every lit body cell green (the snake can be too long for per-cell regions); + // a red region over the food pixel wins where it overlaps. + const uint16_t bg = graphics::getThemeBodyBg(); + graphics::registerTFTColorRegionDirect(ox, oy, boardW, boardH, graphics::TFTPalette::MeshtasticGreen, bg); + graphics::registerTFTColorRegionDirect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2, graphics::TFTPalette::Red, bg); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Snake.h b/src/modules/games/Snake.h new file mode 100644 index 000000000..293707ff4 --- /dev/null +++ b/src/modules/games/Snake.h @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#include + +/** + * Pure, self-contained Snake game logic. + * + * Deliberately free of Arduino / Screen / heap dependencies so it can be unit-tested natively + * (see test/test_snake) and reused by the Snake adapter below without pulling in the display stack. + * + * The board is a fixed GRID_W x GRID_H grid of cells. The snake body lives in a ring buffer + * sized to the whole board, plus an occupancy bitmap for O(1) collision and food-placement + * checks. No dynamic allocation: total state is ~1 KB and statically sized. + */ +class SnakeGame +{ + public: + // Playfield dimensions in cells. Chosen so that at CELL_PX = 4 the board is 128x48, leaving + // a 16 px score bar at the top of a 128x64 OLED (see Snake.cpp for the pixel layout). + static constexpr uint8_t GRID_W = 32; + static constexpr uint8_t GRID_H = 12; + static constexpr uint16_t CELL_COUNT = static_cast(GRID_W) * GRID_H; // 384 + + // Initial snake length at the start of a game. + static constexpr uint8_t START_LEN = 3; + + enum Direction : uint8_t { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT }; + + struct Cell { + uint8_t x; + uint8_t y; + }; + + /** + * (Re)start a game. The snake spawns horizontally in the middle of the board heading right, + * and the first food is placed. `seed` drives deterministic food placement (xorshift32). + */ + void reset(uint32_t seed); + + /** + * Latch a new heading to be applied on the next step(). A 180-degree reversal of the + * currently-committed direction is rejected (returns false) because it would immediately + * run the head into the neck. Comparing against the committed direction (not the pending + * one) means multiple key presses within a single tick can't chain into a reversal. + */ + bool setDirection(Direction d); + + /** + * Advance the simulation by one tick. Returns true if the snake is still alive afterwards, + * false if this move ended the game (wall hit, self-collision, or board filled == win). + * Once dead, further step() calls are no-ops returning false. + */ + bool step(); + + bool isPlaying() const { return alive; } + bool isWon() const { return won; } + uint16_t length() const { return len; } + uint32_t score() const { return points; } + + Cell head() const { return body[headIdx]; } + Cell food() const { return foodCell; } + Direction direction() const { return dir; } + + /// True if cell (x,y) is currently part of the snake body. + bool occupied(uint8_t x, uint8_t y) const { return getOcc(cellIndex(x, y)); } + + /// Iterate the body from tail (i == 0) to head (i == length()-1); used by the renderer. + Cell bodyAt(uint16_t i) const { return body[(tailIdx + i) % CAP]; } + + /** + * Test/aid seam: force the next food to a specific cell so unit tests can drive + * deterministic growth. Unused in production. Caller must pass an unoccupied cell. + */ + void placeFoodAt(uint8_t x, uint8_t y) { foodCell = {x, y}; } + + private: + static constexpr uint16_t CAP = CELL_COUNT; // ring capacity == board size (len is tracked explicitly) + + Cell body[CAP] = {0}; + uint16_t headIdx = 0; // ring index of the head (front) cell + uint16_t tailIdx = 0; // ring index of the tail (oldest) cell + uint16_t len = 0; // number of live body cells + + uint8_t occ[(CELL_COUNT + 7) / 8] = {0}; // occupancy bitmap, indexed by cellIndex() + + Cell foodCell = {0, 0}; + Direction dir = DIR_RIGHT; + Direction pendingDir = DIR_RIGHT; + uint32_t points = 0; + uint32_t rng = 1; // xorshift32 state (never 0) + bool alive = false; + bool won = false; + + static uint16_t cellIndex(uint8_t x, uint8_t y) { return static_cast(y) * GRID_W + x; } + bool getOcc(uint16_t idx) const { return (occ[idx >> 3] >> (idx & 7)) & 1u; } + void setOcc(uint16_t idx) { occ[idx >> 3] |= static_cast(1u << (idx & 7)); } + void clearOcc(uint16_t idx) { occ[idx >> 3] &= static_cast(~(1u << (idx & 7))); } + + uint32_t nextRandom(); + bool placeFood(); // returns false if the board is full (no free cell -> win) + static bool isReverse(Direction a, Direction b); +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Snake as a hosted Game. Wraps the pure SnakeGame logic above and supplies the attract art, the + * playfield renderer, the direction input, the length-based speed curve, and its own high-score + * table. The new-high-score mesh announcement is shared by all games and lives in GamesModule. + */ +class Snake : public Game +{ + public: + Snake(); + + const char *name() const override { return "Snake"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override { return game.step(); } + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override; + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record; layout preserved from the original SnakeModule so snake.dat keeps + // loading. Magic 'SNEK', file version 1. + struct SnakeEntry { + uint32_t score; + uint32_t nodeNum; + char shortName[5]; // three characters, NUL-terminated + uint32_t epoch; // getValidTime(), 0 if no RTC + } __attribute__((packed)); + + SnakeGame game; + HighScoreTable scores_{"/prefs/snake.dat", 0x534E454Bu, 1, "Snake"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Tetris.cpp b/src/modules/games/Tetris.cpp new file mode 100644 index 000000000..7c24b3269 --- /dev/null +++ b/src/modules/games/Tetris.cpp @@ -0,0 +1,494 @@ +#include "Tetris.h" + +// =========================================================================== +// Pure TetrisGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +// --------------------------------------------------------------------------- +// Shape table +// +// SHAPES[type][rot][row] encodes each row of the 4×4 bounding box as a 4-bit +// column bitmask (bit 0 = leftmost column). All seven SRS tetrominoes in their +// four CW rotations. +// +// Type 0 I Type 1 O Type 2 T Type 3 S +// Type 4 Z Type 5 J Type 6 L +// --------------------------------------------------------------------------- +const uint8_t TetrisGame::SHAPES[PIECE_TYPES][4][4] = { + // I ---- rot0: .XXXX rot1: ..X.. rot2: ..... rot3: .X... + {{0, 15, 0, 0}, {4, 4, 4, 4}, {0, 0, 15, 0}, {2, 2, 2, 2}}, + // O ---- same all rotations + {{6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}}, + // T ---- + {{2, 7, 0, 0}, {2, 6, 2, 0}, {0, 7, 2, 0}, {2, 3, 2, 0}}, + // S ---- + {{6, 3, 0, 0}, {1, 3, 2, 0}, {0, 6, 3, 0}, {2, 6, 4, 0}}, + // Z ---- + {{3, 6, 0, 0}, {2, 3, 1, 0}, {0, 3, 6, 0}, {4, 6, 2, 0}}, + // J ---- + {{1, 7, 0, 0}, {6, 2, 2, 0}, {0, 7, 4, 0}, {2, 2, 3, 0}}, + // L ---- + {{4, 7, 0, 0}, {2, 2, 6, 0}, {0, 7, 1, 0}, {3, 2, 2, 0}}, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +bool TetrisGame::pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc) +{ + if (type >= PIECE_TYPES || rot >= 4 || pr >= 4 || pc >= 4) + return false; + return (SHAPES[type][rot][pr] >> pc) & 1u; +} + +uint32_t TetrisGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +TetrisGame::Piece TetrisGame::spawnPiece(uint8_t type) const +{ + // Centre horizontally in the 10-wide board; bounding box starts at col 3. + Piece p; + p.type = type; + p.rot = 0; + p.col = static_cast((BOARD_COLS - 4) / 2); // 3 for a 10-wide board + p.row = 0; + return p; +} + +bool TetrisGame::canPlace(const Piece &p) const +{ + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!pieceCell(p.type, p.rot, pr, pc)) + continue; + const int16_t br = static_cast(p.row) + pr; + const int16_t bc = static_cast(p.col) + pc; + if (br < 0) + continue; // above the board - allowed during spawn + if (br >= BOARD_ROWS || bc < 0 || bc >= BOARD_COLS) + return false; + if (board[br][bc] != 0) + return false; + } + } + return true; +} + +void TetrisGame::lockPiece() +{ + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!pieceCell(cur.type, cur.rot, pr, pc)) + continue; + const int16_t br = static_cast(cur.row) + pr; + const int16_t bc = static_cast(cur.col) + pc; + if (br >= 0 && br < BOARD_ROWS && bc >= 0 && bc < BOARD_COLS) + board[br][bc] = static_cast(cur.type + 1); // colour 1..7 + } + } +} + +int TetrisGame::clearLines() +{ + int cleared = 0; + for (int r = BOARD_ROWS - 1; r >= 0;) { + bool full = true; + for (int c = 0; c < BOARD_COLS && full; c++) { + if (board[r][c] == 0) + full = false; + } + if (full) { + // Shift every row above down by one. + for (int rr = r; rr > 0; rr--) + memcpy(board[rr], board[rr - 1], BOARD_COLS); + memset(board[0], 0, BOARD_COLS); + cleared++; + // Recheck same index - it now contains the row that was above. + } else { + r--; + } + } + return cleared; +} + +void TetrisGame::advanceNext() +{ + lockPiece(); + + const int cleared = clearLines(); + if (cleared > 0) { + lines += static_cast(cleared); + // Nintendo-style line-clear scoring (×level). + static const uint16_t LINE_PTS[5] = {0, 100, 300, 500, 800}; + pts += LINE_PTS[cleared < 5 ? cleared : 4] * lvl; + // Level up every 10 lines, cap at 20. + const uint8_t newLvl = static_cast(lines / 10 + 1); + lvl = newLvl > 20 ? 20 : newLvl; + } + + // nxt becomes the active piece; generate a fresh nxt. + cur = nxt; + if (!canPlace(cur)) { + alive = false; + return; + } + nxt = spawnPiece(static_cast(nextRandom() % PIECE_TYPES)); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +void TetrisGame::reset(uint32_t seed) +{ + memset(board, 0, sizeof(board)); + pts = 0; + lvl = 1; + lines = 0; + alive = true; + rng = seed ? seed : 0xA5A5A5A5u; + cur = spawnPiece(static_cast(nextRandom() % PIECE_TYPES)); + nxt = spawnPiece(static_cast(nextRandom() % PIECE_TYPES)); +} + +bool TetrisGame::moveLeft() +{ + Piece p = cur; + p.col--; + if (!canPlace(p)) + return false; + cur = p; + return true; +} + +bool TetrisGame::moveRight() +{ + Piece p = cur; + p.col++; + if (!canPlace(p)) + return false; + cur = p; + return true; +} + +bool TetrisGame::rotate() +{ + Piece p = cur; + p.rot = static_cast((p.rot + 1) % 4); + if (canPlace(p)) { + cur = p; + return true; + } + // Wall-kick: try ±1, ±2 column offsets. + const int8_t kicks[] = {-1, 1, -2, 2}; + for (int8_t kick : kicks) { + Piece q = p; + q.col = static_cast(p.col + kick); + if (canPlace(q)) { + cur = q; + return true; + } + } + return false; +} + +bool TetrisGame::softDrop() +{ + Piece p = cur; + p.row++; + if (!canPlace(p)) { + advanceNext(); // locks cur, clears lines, spawns next; may set alive=false + return false; + } + cur = p; + return true; +} + +void TetrisGame::hardDrop() +{ + const int8_t land = ghostRow(); + const uint32_t dropped = static_cast(land - cur.row); + pts += dropped * 2; + cur.row = land; + advanceNext(); +} + +int8_t TetrisGame::ghostRow() const +{ + Piece p = cur; + while (true) { + Piece q = p; + q.row++; + if (!canPlace(q)) + break; + p = q; + } + return p.row; +} + +bool TetrisGame::step() +{ + if (!alive) + return false; + softDrop(); // may lock and advance, potentially setting alive=false + return alive; +} + +// =========================================================================== +// Tetris adapter (display + persistence + mesh; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "GamesModule.h" +#include "NodeDB.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" +#include +#include + +// --------------------------------------------------------------------------- +// Vertical pixel layout on a 128×64 OLED +// +// Board occupies the left side of the screen: +// x = col × CELL_PX (col 0 at left edge) +// y = row × CELL_PX (row 0 at top edge) +// 10 cols × 4 px = 40 px wide +// 16 rows × 4 px = 64 px tall (fills the full display height) +// +// Score panel: x = SCORE_OX .. 127 (86 px wide) +// Labels (SCR / LVL / NXT) + values + next-piece preview. +// --------------------------------------------------------------------------- +static constexpr int16_t CELL_PX = 4; + +Tetris::Tetris() +{ + scores_.load(); +} + +int32_t Tetris::tickIntervalMs() const +{ + // Speed ramps with level: 600 ms base, 45 ms per level, floor 50 ms. + int32_t iv = 600 - static_cast(game.level()) * 45; + return iv < 50 ? 50 : iv; +} + +void Tetris::handleInput(input_broker_event ev) +{ + switch (ev) { + case INPUT_BROKER_UP: + game.rotate(); + break; + case INPUT_BROKER_LEFT: + game.moveLeft(); + break; + case INPUT_BROKER_RIGHT: + game.moveRight(); + break; + case INPUT_BROKER_DOWN: + game.softDrop(); + break; + case INPUT_BROKER_SELECT: + case INPUT_BROKER_SELECT_LONG: + game.hardDrop(); + break; + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +#if GRAPHICS_TFT_COLORING_ENABLED +// Classic tetromino colours, indexed by piece type (0..6 == I O T S Z J L). Native RGB565. +static uint16_t tetrominoColor(uint8_t type) +{ + using namespace graphics; + switch (type) { + case 0: + return TFTPalette::Cyan; // I + case 1: + return TFTPalette::Yellow; // O + case 2: + return TFTPalette::Magenta; // T + case 3: + return TFTPalette::Green; // S + case 4: + return TFTPalette::Red; // Z + case 5: + return TFTPalette::Blue; // J + case 6: + return TFTPalette::Orange; // L + default: + return TFTPalette::White; + } +} +#endif + +void Tetris::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "T E T R I S"); + const int16_t logoX = x + (w - tetris_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, tetris_width, tetris_height, tetris); +#if GRAPHICS_TFT_COLORING_ENABLED + // The logo glyph is a T tetromino -- tint it the T-piece colour on colour displays. + graphics::registerTFTColorRegionDirect(logoX, logoY, tetris_width, tetris_height, tetrominoColor(2), + graphics::getThemeBodyBg()); +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +void Tetris::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + // Centered vertical layout: + // board: 10 cols × CELL_PX wide, fills display height (BOARD_ROWS × CELL_PX) + // left panel (NXT preview) : x = 0 .. ox-2 + // right panel (SCR / LVL) : x = ox+boardW+1 .. display.width-1 + const int16_t boardW = TetrisGame::BOARD_COLS * CELL_PX; // 40 + const int16_t ox = x + (display->getWidth() - boardW) / 2; // horizontal centre + const int16_t oy = y; + + display->setColor(WHITE); + + // Separator lines either side of the board, plus bottom wall. + display->drawLine(ox - 1, oy, ox - 1, oy + display->getHeight() - 1); + display->drawLine(ox + boardW, oy, ox + boardW, oy + display->getHeight() - 1); + display->drawLine(ox - 1, oy + display->getHeight() - 1, ox + boardW, oy + display->getHeight() - 1); + + // Cell helper. + auto drawCell = [&](int8_t col, int8_t row) { + if (col < 0 || row < 0 || col >= TetrisGame::BOARD_COLS || row >= TetrisGame::BOARD_ROWS) + return; + display->fillRect(ox + static_cast(col) * CELL_PX, oy + static_cast(row) * CELL_PX, CELL_PX - 1, + CELL_PX - 1); + }; + + // Locked cells. + for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) + for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++) + if (game.board[r][c]) + drawCell(static_cast(c), static_cast(r)); + + // Ghost piece - hollow outline. + const TetrisGame::Piece &cur = game.current(); + const int8_t ghostR = game.ghostRow(); + if (ghostR != cur.row) { + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc)) + continue; + const int8_t gc = static_cast(cur.col + pc); + const int8_t gr = static_cast(ghostR + pr); + if (gc < 0 || gr < 0 || gc >= TetrisGame::BOARD_COLS || gr >= TetrisGame::BOARD_ROWS) + continue; + display->setPixel(ox + static_cast(gc) * CELL_PX + 1, oy + static_cast(gr) * CELL_PX + 1); + } + } + } + + // Active piece - filled. + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc)) + continue; + drawCell(static_cast(cur.col + pc), static_cast(cur.row + pr)); + } + } + + // --- Right panel: SCR and LVL --- + const int16_t rpx = ox + boardW + 2; + char buf[12]; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(rpx, y + 2, "SCR"); + snprintf(buf, sizeof(buf), "%lu", static_cast(game.score())); + display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL, buf); + display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 2 + 2, "LVL"); + snprintf(buf, sizeof(buf), "%u", static_cast(game.level())); + display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 3 + 2, buf); + + // --- Left panel: NXT (next piece preview) centred in the panel --- + const int16_t lpanelW = ox - 2; // pixels available left of board separator + static constexpr int16_t PREV_PX = 3; // px per preview cell + const int16_t previewW = 4 * PREV_PX; // 12 px + const int16_t lpx = x + (lpanelW - previewW) / 2; + const int16_t nxtLabelY = y + 2; + const int16_t nxtPreviewY = nxtLabelY + FONT_HEIGHT_SMALL + 2; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + lpanelW / 2, nxtLabelY, "NXT"); + const TetrisGame::Piece &nxt = game.next(); + for (uint8_t pr = 0; pr < 4; pr++) + for (uint8_t pc = 0; pc < 4; pc++) + if (TetrisGame::pieceCell(nxt.type, nxt.rot, pr, pc)) + display->fillRect(lpx + static_cast(pc) * PREV_PX, nxtPreviewY + static_cast(pr) * PREV_PX, + PREV_PX - 1, PREV_PX - 1); + +#if GRAPHICS_TFT_COLORING_ENABLED + // On a colour display (e.g. HUB75), tint every block with its tetromino colour. The mono buffer + // still carries the block pixels drawn above; registering a colour region over each run of + // same-colour cells makes those "on" pixels render in colour instead of the theme foreground. + // Runs are merged horizontally per row to stay within the region budget, and empty cells cost + // nothing, so the region count only grows with how full the board is. + const uint16_t bg = graphics::getThemeBodyBg(); + + // Combined colour grid: locked cells plus the falling piece (same colour source == type + 1). + uint8_t cg[TetrisGame::BOARD_ROWS][TetrisGame::BOARD_COLS]; + for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) + for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++) + cg[r][c] = game.board[r][c]; + for (uint8_t pr = 0; pr < 4; pr++) + for (uint8_t pc = 0; pc < 4; pc++) { + if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc)) + continue; + const int br = cur.row + pr, bc = cur.col + pc; + if (br >= 0 && br < TetrisGame::BOARD_ROWS && bc >= 0 && bc < TetrisGame::BOARD_COLS) + cg[br][bc] = static_cast(cur.type + 1); + } + for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) { + uint8_t c = 0; + while (c < TetrisGame::BOARD_COLS) { + const uint8_t v = cg[r][c]; + if (v == 0) { + c++; + continue; + } + const uint8_t c0 = c; + while (c < TetrisGame::BOARD_COLS && cg[r][c] == v) + c++; + const int16_t rx = ox + static_cast(c0) * CELL_PX; + const int16_t ry = oy + static_cast(r) * CELL_PX; + const int16_t rw = static_cast(c - c0) * CELL_PX - 1; // span the run, drop the trailing gap + graphics::registerTFTColorRegionDirect(rx, ry, rw, CELL_PX - 1, tetrominoColor(static_cast(v - 1)), bg); + } + } + // Next-piece preview: one region over the 4x4 grid tinted with its colour. + graphics::registerTFTColorRegionDirect(lpx, nxtPreviewY, 4 * PREV_PX, 4 * PREV_PX, tetrominoColor(nxt.type), bg); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Tetris.h b/src/modules/games/Tetris.h new file mode 100644 index 000000000..4b429ec2e --- /dev/null +++ b/src/modules/games/Tetris.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include + +/** + * Pure, self-contained Tetris game logic. + * + * No Arduino/display dependencies - designed to be unit-tested natively and reused by the Tetris + * adapter below without pulling in the display stack. + * + * Board coordinate system: col=0 is leftmost, row=0 is top (gravity goes toward higher rows). + * board[row][col] holds 0 (empty) or 1..7 (locked piece colour index). + * No dynamic allocation: total struct size is ~260 bytes. + */ +class TetrisGame +{ + public: + static constexpr uint8_t BOARD_COLS = 10; + static constexpr uint8_t BOARD_ROWS = 16; // 16×4 px = 64 px - fills a standard 64px OLED + static constexpr uint8_t PIECE_TYPES = 7; // I O T S Z J L + + struct Piece { + int8_t col; // left column of the 4×4 bounding box (may be negative during spawn) + int8_t row; // top row of the 4×4 bounding box (may be negative during spawn) + uint8_t type; // 0..6 + uint8_t rot; // 0..3 + }; + + // board[row][col]: 0 = empty, 1..7 = locked piece colour + uint8_t board[BOARD_ROWS][BOARD_COLS]; + + /** Start (or restart) the game. seed drives the xorshift32 RNG. */ + void reset(uint32_t seed); + + /** Shift the current piece left one column. Returns true if it moved. */ + bool moveLeft(); + + /** Shift the current piece right one column. Returns true if it moved. */ + bool moveRight(); + + /** + * Rotate the current piece CW. Tries a basic wall-kick (±1, ±2 column) if the + * natural rotation overlaps a wall or locked cell. Returns true if it rotated. + */ + bool rotate(); + + /** + * Move the current piece down one row. If it cannot fall it is locked, + * lines are cleared, the next piece becomes current, and a new next is generated. + * Returns false after locking (game may or may not be over). + */ + bool softDrop(); + + /** + * Instantly drop the current piece to where it would land and lock it. + * Awards 2 pts per row dropped. + */ + void hardDrop(); + + /** + * Gravity tick: same as softDrop() - move down one row, lock if needed. + * Returns true while the game is alive, false after game-over. + */ + bool step(); + + bool isPlaying() const { return alive; } + uint32_t score() const { return pts; } + uint8_t level() const { return lvl; } + uint16_t linesCleared() const { return lines; } + + const Piece ¤t() const { return cur; } + const Piece &next() const { return nxt; } + + /** + * Returns the top row the current piece would occupy if instantly dropped. + * Used by the renderer to show a ghost/shadow piece. + */ + int8_t ghostRow() const; + + /** + * Returns true if cell (pr, pc) within the 4×4 bounding box is filled for + * the given piece type and rotation. Safe for any (type, rot, pr, pc). + */ + static bool pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc); + + private: + Piece cur = {}; + Piece nxt = {}; + uint32_t pts = 0; + uint8_t lvl = 1; + uint16_t lines = 0; + uint32_t rng = 1; // xorshift32 state - must never be 0 + bool alive = false; + + uint32_t nextRandom(); + Piece spawnPiece(uint8_t type) const; + void advanceNext(); // lock cur, clear lines, shift nxt→cur, spawn new nxt + bool canPlace(const Piece &p) const; + void lockPiece(); + int clearLines(); // returns number of lines cleared (0..4) + + // Shape table: SHAPES[type][rot][row] = 4-bit column bitmask. + // Bit 0 = leftmost column (col 0), bit 3 = rightmost (col 3) of the 4×4 box. + static const uint8_t SHAPES[PIECE_TYPES][4][4]; +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Tetris as a hosted Game. Wraps the pure TetrisGame logic above and supplies the attract art, the + * portrait playfield renderer, the rotate/move/drop input, the level-based speed curve, and its + * own high-score table. The new-high-score mesh announcement is shared by all games and lives in + * GamesModule. + */ +class Tetris : public Game +{ + public: + Tetris(); + + const char *name() const override { return "Tetris"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override { return game.step(); } + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override; + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + const char *gameOverHint() const override { return "SEL: scores BCK: exit"; } + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record; layout preserved from the original TetrisModule so tetris.dat + // keeps loading. Magic 'TETR', file version 1. + struct TetrisEntry { + uint32_t score; + char shortName[5]; // NUL-terminated 3-char display name + uint32_t nodeNum; + uint32_t epoch; + } __attribute__((packed)); + + TetrisGame game; + HighScoreTable scores_{"/prefs/tetris.dat", 0x54455452u, 1, "Tetris"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 8482dafe4..6847b9aa4 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -60,6 +60,10 @@ class AccelerometerThread : public concurrency::OSThread } } + // True if the active sensor drives the compass heading in runOnce(). Callers must not + // disable() the thread in this case, or the compass would freeze until the next reboot. + bool providesHeading() const { return isInitialised && sensor && sensor->providesHeading(); } + protected: int32_t runOnce() override { diff --git a/src/motion/BMM150Sensor.h b/src/motion/BMM150Sensor.h index 879045400..8c5b1244e 100644 --- a/src/motion/BMM150Sensor.h +++ b/src/motion/BMM150Sensor.h @@ -50,6 +50,7 @@ class BMM150Sensor : public MotionSensor // Called each time our sensor gets a chance to run virtual int32_t runOnce() override; + virtual bool providesHeading() const override { return true; } }; #endif diff --git a/src/motion/BMX160Sensor.h b/src/motion/BMX160Sensor.h index d60477521..d0d21d3dd 100755 --- a/src/motion/BMX160Sensor.h +++ b/src/motion/BMX160Sensor.h @@ -25,6 +25,7 @@ class BMX160Sensor : public MotionSensor virtual bool init() override; virtual int32_t runOnce() override; virtual void calibrate(uint16_t forSeconds) override; + virtual bool providesHeading() const override { return true; } }; #else diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h index d8369b3ca..e84c7ea1b 100755 --- a/src/motion/ICM20948Sensor.h +++ b/src/motion/ICM20948Sensor.h @@ -100,6 +100,7 @@ class ICM20948Sensor : public MotionSensor // Called each time our sensor gets a chance to run virtual int32_t runOnce() override; virtual void calibrate(uint16_t forSeconds) override; + virtual bool providesHeading() const override { return true; } }; #endif diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h index 717c89a9b..38876fb77 100755 --- a/src/motion/MotionSensor.h +++ b/src/motion/MotionSensor.h @@ -42,6 +42,12 @@ class MotionSensor virtual void calibrate(uint16_t forSeconds){}; + // True if this sensor produces the compass heading (screen->setHeading()) in runOnce(). + // Combined accel+magnetometer parts (e.g. BMX160, ICM20948) and standalone magnetometers + // handled by the accelerometer thread (e.g. BMM150) override this. Used to avoid halting + // the thread - and freezing the compass - when motion-only features are disabled at runtime. + inline virtual bool providesHeading() const { return false; }; + protected: // Turn on the screen when a tap or motion is detected virtual void wakeScreen(); diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index b6f0ce24d..907888bff 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -38,7 +38,7 @@ #include #define ntohl __ntohl #endif -#include +#include MQTT *mqtt; diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 4b52eae83..647c1bef8 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -409,6 +409,8 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread uint8_t val[4]; put_le32(val, fromRadioNum); + if (!fromNumCharacteristic) // BLE may have been torn down; never notify a freed characteristic + return; fromNumCharacteristic->setValue(val, sizeof(val)); fromNumCharacteristic->notify(); } @@ -694,6 +696,35 @@ class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks } }; +// Reset per-session PhoneAPI and transport state. Runs from onDisconnect, and again from +// setupService() on BLE re-enable because deinit()'s bounded disconnect wait can expire +// before the disconnect event delivers this cleanup (leaving stale queues/state behind). +static void resetBleSessionState() +{ + if (bluetoothPhoneAPI) { + bluetoothPhoneAPI->close(); + + { // scope for fromPhoneMutex mutex + std::lock_guard guard(bluetoothPhoneAPI->fromPhoneMutex); + bluetoothPhoneAPI->fromPhoneQueueSize = 0; + } + + bluetoothPhoneAPI->onReadCallbackIsWaitingForData = false; + { // scope for toPhoneMutex mutex + std::lock_guard guard(bluetoothPhoneAPI->toPhoneMutex); + bluetoothPhoneAPI->toPhoneQueueSize = 0; + } + + bluetoothPhoneAPI->readCount = 0; + bluetoothPhoneAPI->notifyCount = 0; + bluetoothPhoneAPI->writeCount = 0; + } + + memset(lastToRadio, 0, sizeof(lastToRadio)); + + nimbleBluetoothConnHandle = BLE_HS_CONN_HANDLE_NONE; +} + class NimbleBluetoothServerCallback : public BLEServerCallbacks { public: @@ -736,28 +767,7 @@ class NimbleBluetoothServerCallback : public BLEServerCallbacks bluetoothStatus->updateStatus(&newStatus); clearPairingDisplay(); - if (bluetoothPhoneAPI) { - bluetoothPhoneAPI->close(); - - { // scope for fromPhoneMutex mutex - std::lock_guard guard(bluetoothPhoneAPI->fromPhoneMutex); - bluetoothPhoneAPI->fromPhoneQueueSize = 0; - } - - bluetoothPhoneAPI->onReadCallbackIsWaitingForData = false; - { // scope for toPhoneMutex mutex - std::lock_guard guard(bluetoothPhoneAPI->toPhoneMutex); - bluetoothPhoneAPI->toPhoneQueueSize = 0; - } - - bluetoothPhoneAPI->readCount = 0; - bluetoothPhoneAPI->notifyCount = 0; - bluetoothPhoneAPI->writeCount = 0; - } - - memset(lastToRadio, 0, sizeof(lastToRadio)); - - nimbleBluetoothConnHandle = BLE_HS_CONN_HANDLE_NONE; + resetBleSessionState(); // Defer the advertising restart to runOnce (see pendingStartAdvertising): calling // startAdvertising() here would crash if this disconnect was a host reset. @@ -769,9 +779,6 @@ class NimbleBluetoothServerCallback : public BLEServerCallbacks } }; -static NimbleBluetoothToRadioCallback *toRadioCallbacks; -static NimbleBluetoothFromRadioCallback *fromRadioCallbacks; - void NimbleBluetooth::startAdvertising() { BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); @@ -840,7 +847,14 @@ void NimbleBluetooth::deinit() BLEDevice::deinit(true); bleServer = nullptr; // deleted by deinit(); clear the dangling copy BatteryCharacteristic = nullptr; // freed by deinit; clear so updateBatteryLevel() won't touch it + fromNumCharacteristic = nullptr; // freed by deinit; a late onNowHasData() must not notify freed memory + logRadioCharacteristic = nullptr; lastBatteryLevel = -1; + + // The bounded disconnect wait above can expire before onDisconnect runs, leaving the PhoneAPI + // observer attached with a live state machine; a later mesh packet would then drive onNowHasData() + // into the now-freed characteristics. Detach the observer and reset session state unconditionally. + resetBleSessionState(); #endif } @@ -858,7 +872,7 @@ int NimbleBluetooth::getRssi() { #if defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) uint16_t conn_handle = nimbleBluetoothConnHandle.load(); - if (conn_handle != BLE_HS_CONN_HANDLE_NONE) { + if (conn_handle == BLE_HS_CONN_HANDLE_NONE) { return 0; // No active BLE connection } @@ -903,33 +917,36 @@ void NimbleBluetooth::setup() LOG_WARN("Unable to request MTU %u, rc=%d", kPreferredBleMtu, mtuResult); } - BLESecurity *pSecurity = new BLESecurity(); - pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); - pSecurity->setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); + // BLESecurity only forwards to static NimBLEDevice setters; a stack instance suffices. + BLESecurity security; + security.setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); + security.setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { // Set IO capability to DisplayOnly for MITM authentication - pSecurity->setCapability(ESP_IO_CAP_OUT); + security.setCapability(ESP_IO_CAP_OUT); // Set the passkey if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN) { LOG_INFO("Use random passkey"); - pSecurity->setPassKey(false); // generate a random passkey + security.setPassKey(false); // generate a random passkey } else { LOG_INFO("Use fixed passkey"); - pSecurity->setPassKey(true, config.bluetooth.fixed_pin); + security.setPassKey(true, config.bluetooth.fixed_pin); } // Enable authorization requirements: // - bonding: true (for persistent storage of the keys) // - MITM: true (enables Man-In-The-Middle protection for password prompts) // - secure connection: true (enables secure connection for encryption) - pSecurity->setAuthenticationMode(true, true, true); + security.setAuthenticationMode(true, true, true); } else { // No IO capability for no PIN mode - pSecurity->setCapability(ESP_IO_CAP_NONE); + security.setCapability(ESP_IO_CAP_NONE); // No PIN mode: no MITM protection - pSecurity->setAuthenticationMode(true, false, false); + security.setAuthenticationMode(true, false, false); } - // Set the security callbacks - BLEDevice::setSecurityCallbacks(new NimbleBluetoothSecurityCallback()); + // Statics: setup() re-runs on BLE re-enable, and the library never frees these + // caller-owned callback objects, so register the same instances every cycle. + static NimbleBluetoothSecurityCallback securityCallbacks; + BLEDevice::setSecurityCallbacks(&securityCallbacks); bleServer = BLEDevice::createServer(); // BLEDevice::createServer calls ble_svc_gap_init, which resets the device @@ -939,7 +956,8 @@ void NimbleBluetooth::setup() LOG_ERROR("ble_svc_gap_device_name_set: rc=%d %s", nameRc, BLEUtils::returnCodeToString(nameRc)); } - bleServer->setCallbacks(new NimbleBluetoothServerCallback(this)); + static NimbleBluetoothServerCallback serverCallbacks(this); // safe: NimbleBluetooth is a never-deleted singleton + bleServer->setCallbacks(&serverCallbacks); setupService(); startAdvertising(); } @@ -972,13 +990,20 @@ void NimbleBluetooth::setupService() LOGRADIO_UUID, BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_READ_AUTHEN | BLECharacteristic::PROPERTY_READ_ENC); } - bluetoothPhoneAPI = new BluetoothPhoneAPI(); + // setupService() re-runs on BLE re-enable; a fresh BluetoothPhoneAPI here would leak + // the old one as a still-scheduled zombie OSThread, so reuse it and reset its state + // (a skipped onDisconnect during deinit() can leave the previous session's behind). + if (!bluetoothPhoneAPI) + bluetoothPhoneAPI = new BluetoothPhoneAPI(); + else + resetBleSessionState(); - toRadioCallbacks = new NimbleBluetoothToRadioCallback(); - ToRadioCharacteristic->setCallbacks(toRadioCallbacks); + // The characteristics are new each cycle, so setCallbacks() must re-run every time. + static NimbleBluetoothToRadioCallback toRadioCallbacks; + ToRadioCharacteristic->setCallbacks(&toRadioCallbacks); - fromRadioCallbacks = new NimbleBluetoothFromRadioCallback(); - FromRadioCharacteristic->setCallbacks(fromRadioCallbacks); + static NimbleBluetoothFromRadioCallback fromRadioCallbacks; + FromRadioCharacteristic->setCallbacks(&fromRadioCallbacks); bleService->start(); diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 25714e543..afbf4482d 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -27,6 +27,8 @@ #include "target_specific.h" #include #include +#include +#include #include #include @@ -158,6 +160,26 @@ void getMacAddr(uint8_t *dmac) #endif } +bool getDeviceId(uint8_t *deviceId) +{ +#if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3) || \ + defined(CONFIG_IDF_TARGET_ESP32C6) + // ESP32 factory burns a 128-bit unique id in efuse for S2+ and C3+ series (used for HMACs + // in the esp-rainmaker AIOT platform); a good stable hardware anchor for us too. + uint32_t unique_id[4]; + esp_err_t err = esp_efuse_read_field_blob(ESP_EFUSE_OPTIONAL_UNIQUE_ID, unique_id, sizeof(unique_id) * 8); + if (err != ESP_OK) { + LOG_WARN("Failed to read unique id from efuse"); + return false; + } + memcpy(deviceId, unique_id, sizeof(unique_id)); + return true; +#else + // Classic ESP32 has no OPTIONAL_UNIQUE_ID efuse: fall back to the factory-burned MAC. + return getMacAddrDeviceId(deviceId); +#endif +} + #if HAS_32768HZ #define CALIBRATE_ONE(cali_clk) calibrate_one(cali_clk, #cali_clk) diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index a829bb980..9a38ddf73 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -418,19 +418,20 @@ void t5BacklightHandleUserInput() void t5TouchSetForcedByTimeout(bool forced) { + if (touchForcedByTimeout == forced) { + return; + } + touchForcedByTimeout = forced; touchStateEpoch++; touchIndicatorRefreshPending = true; if (forced) { - // While timeout-forced, keep controller asleep to avoid stale IRQ chatter. + // Timeout only gates touch input in software. Avoid GT911 I2C here because + // PowerFSM same-state transitions can fire from phone contact and screen timeout. touchNeedsWake = false; - if (touchControllerReady && !touchLightSleepActive) { - touch.sleep(); - } } else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) { - // Defer wake until readTouch() so I2C settles post-state transition. - touchNeedsWake = true; + touchNeedsWake = false; } #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index dd0230100..357c1484c 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -369,6 +369,15 @@ void NRF52Bluetooth::setup() } void NRF52Bluetooth::resumeAdvertising() { + // shutdown() latches onUnwantedPairing to actively refuse pairing (used on the factory-reset / + // BT-disable teardown path). The real pairing passkey callback is installed only in setup(), but a + // shutdown()->resumeAdvertising() re-enable cycle skips setup() entirely (see setBluetoothEnable() in + // main-nrf52.cpp), so restore the correct callback here - otherwise the device silently refuses all + // pairing until the next reboot. Mirror the mode check in setup(): only PIN modes drive a passkey- + // display callback, so NO_PIN (Just Works) needs no restore. + if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) + Bluefruit.Security.setPairPasskeyCallback(NRF52Bluetooth::onPairingPasskey); + Bluefruit.Advertising.restartOnDisconnect(true); Bluefruit.Advertising.setInterval(32, 668); // in unit of 0.625 ms Bluefruit.Advertising.setFastTimeout(30); // number of seconds in fast mode diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index f120d387c..ae12ef4a0 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -58,6 +58,15 @@ void variant_shutdown() {} void variant_nrf52LoopHook(void) __attribute__((weak)); void variant_nrf52LoopHook(void) {} +// Return false to skip LPCOMP wake when entering System OFF (e.g. user CLI shutdown). +// noinline: weak default and call site are in this file; without it GCC may inline the +// weak body and never link the strong override from variant.cpp. +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() __attribute__((weak)); +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() +{ + return true; +} + static nrfx_wdt_t nrfx_wdt = NRFX_WDT_INSTANCE(0); static nrfx_wdt_channel_id nrfx_wdt_channel_id_nrf52_main; @@ -186,6 +195,17 @@ void getMacAddr(uint8_t *dmac) dmac[0] = src[5] | 0xc0; // MSB high two bits get set elsewhere in the bluetooth stack } +bool getDeviceId(uint8_t *deviceId) +{ + // Nordic burns a FIPS-compliant random id into each chip at the factory. We concatenate + // the device address to that random id to form the 16-byte hardware identifier. + uint64_t device_id_start = ((uint64_t)NRF_FICR->DEVICEID[1] << 32) | NRF_FICR->DEVICEID[0]; + uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; + memcpy(deviceId, &device_id_start, sizeof(device_id_start)); + memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); + return true; +} + #if !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { @@ -485,20 +505,23 @@ void cpuDeepSleep(uint32_t msecToWake) // https://devzone.nordicsemi.com/f/nordic-q-a/48919/ram-retention-settings-with-softdevice-enabled #ifdef BATTERY_LPCOMP_INPUT - // Wake up if power rises again - nrf_lpcomp_config_t c; - c.reference = BATTERY_LPCOMP_THRESHOLD; - c.detection = NRF_LPCOMP_DETECT_UP; - c.hyst = NRF_LPCOMP_HYST_NOHYST; - nrf_lpcomp_configure(NRF_LPCOMP, &c); - nrf_lpcomp_input_select(NRF_LPCOMP, BATTERY_LPCOMP_INPUT); - nrf_lpcomp_enable(NRF_LPCOMP); + // Only enable LPCOMP wake if the variant allows it + if (variant_enableBatteryLpcompWake()) { + // Wake up if power rises again + nrf_lpcomp_config_t c; + c.reference = BATTERY_LPCOMP_THRESHOLD; + c.detection = NRF_LPCOMP_DETECT_UP; + c.hyst = NRF_LPCOMP_HYST_NOHYST; + nrf_lpcomp_configure(NRF_LPCOMP, &c); + nrf_lpcomp_input_select(NRF_LPCOMP, BATTERY_LPCOMP_INPUT); + nrf_lpcomp_enable(NRF_LPCOMP); - battery_adcEnable(); + battery_adcEnable(); - nrf_lpcomp_task_trigger(NRF_LPCOMP, NRF_LPCOMP_TASK_START); - while (!nrf_lpcomp_event_check(NRF_LPCOMP, NRF_LPCOMP_EVENT_READY)) - ; + nrf_lpcomp_task_trigger(NRF_LPCOMP, NRF_LPCOMP_TASK_START); + while (!nrf_lpcomp_event_check(NRF_LPCOMP, NRF_LPCOMP_EVENT_READY)) + ; + } #endif auto ok = sd_power_system_off(); diff --git a/src/platform/nrf54l15/main-nrf54l15.cpp b/src/platform/nrf54l15/main-nrf54l15.cpp index e3e597fc7..b91fe67ee 100644 --- a/src/platform/nrf54l15/main-nrf54l15.cpp +++ b/src/platform/nrf54l15/main-nrf54l15.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "NodeDB.h" @@ -97,6 +98,17 @@ void getMacAddr(uint8_t *dmac) #endif } +bool getDeviceId(uint8_t *deviceId) +{ + // nRF54L15: DEVICEID under the FICR->INFO sub-struct. Read unconditionally so a future build + // lacking NRF_FICR fails loudly rather than silently sharing getMacAddr()'s placeholder MAC. + uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; + uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; + memcpy(deviceId, &device_id_start, sizeof(device_id_start)); + memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); + return true; +} + // ── Bluetooth ───────────────────────────────────────────────────────────────── void setBluetoothEnable(bool enable) diff --git a/src/platform/portduino/HUB75Native.cpp b/src/platform/portduino/HUB75Native.cpp new file mode 100644 index 000000000..55f0c1415 --- /dev/null +++ b/src/platform/portduino/HUB75Native.cpp @@ -0,0 +1,163 @@ +#include "configuration.h" + +#if defined(HAS_HUB75_NATIVE) + +#include "HUB75Native.h" +#include "PortduinoGlue.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include + +HUB75Native::HUB75Native(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C) +{ + // The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306 page layout). The + // panel size comes from config.yaml at runtime (cols*chain x rows*parallel, computed in + // loadConfig()). Config is fully loaded by portduinoSetup() before Screen constructs. + setGeometry(GEOMETRY_RAWMODE, portduino_config.displayWidth, portduino_config.displayHeight); + LOG_DEBUG("HUB75Native %dx%d", portduino_config.displayWidth, portduino_config.displayHeight); +} + +HUB75Native::~HUB75Native() +{ + if (matrix) { + delete matrix; + matrix = nullptr; + } +} + +// Bring up the rpi-rgb-led-matrix driver from config.yaml. The library owns the GPIO pins +// (selected by HardwareMapping) and runs its own refresh thread, so there is no DMA/I2S setup. +bool HUB75Native::connect() +{ + LOG_INFO("Do HUB75 init"); + + rgb_matrix::RGBMatrix::Options options; + options.hardware_mapping = portduino_config.hub75_hardware_mapping.c_str(); + options.rows = portduino_config.hub75_rows; + options.cols = portduino_config.hub75_cols; + options.chain_length = portduino_config.hub75_chain_length; + options.parallel = portduino_config.hub75_parallel; + options.pwm_bits = portduino_config.hub75_pwm_bits; + options.pwm_lsb_nanoseconds = portduino_config.hub75_pwm_lsb_nanoseconds; + options.brightness = portduino_config.hub75_brightness; + options.scan_mode = portduino_config.hub75_scan_mode; + options.row_address_type = portduino_config.hub75_row_address_type; + options.multiplexing = portduino_config.hub75_multiplexing; + options.disable_hardware_pulsing = portduino_config.hub75_disable_hardware_pulsing; + options.show_refresh_rate = portduino_config.hub75_show_refresh_rate; + options.inverse_colors = portduino_config.hub75_inverse_colors; + // RGB order is handled by the library instead of a software swap (see display()). + options.led_rgb_sequence = portduino_config.hub75_led_rgb_sequence.c_str(); + options.limit_refresh_rate_hz = portduino_config.hub75_limit_refresh_rate_hz; + if (!portduino_config.hub75_pixel_mapper_config.empty()) + options.pixel_mapper_config = portduino_config.hub75_pixel_mapper_config.c_str(); + if (!portduino_config.hub75_panel_type.empty()) + options.panel_type = portduino_config.hub75_panel_type.c_str(); + + rgb_matrix::RuntimeOptions runtime; + runtime.gpio_slowdown = portduino_config.hub75_gpio_slowdown; + runtime.daemon = 0; // run in-process; the library starts its own refresh thread + runtime.drop_privileges = 0; // meshtasticd manages its own privileges (keeps GPIO/LoRa access) + + matrix = rgb_matrix::CreateMatrixFromOptions(options, runtime); + if (matrix == nullptr) { + LOG_ERROR("HUB75 CreateMatrixFromOptions() failed (need root / /dev/gpiomem on a Pi?)"); + return false; + } + brightness = portduino_config.hub75_brightness; // library scale is 1..100 + matrix->SetBrightness(brightness); // applies to the matrix and all its FrameCanvases + matrix->Clear(); + // Offscreen buffer for double-buffered, tear-free presentation (see display()). + offscreen = matrix->CreateFrameCanvas(); + return true; +} + +void HUB75Native::display() +{ + if (!matrix || !offscreen) + return; + + const uint16_t onNative = graphics::TFTPalette::White; + const uint16_t offNative = graphics::getThemeBodyBg(); + + const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8)); + const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8)); + +#if GRAPHICS_TFT_COLORING_ENABLED + const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0; +#endif + + // Full-frame redraw into the offscreen canvas every call (no dirty-diff needed at these + // resolutions), then SwapOnVSync() below presents it atomically for tear-free output. + for (uint16_t y = 0; y < displayHeight; y++) { + const uint32_t yByteIndex = (y / 8) * displayWidth; + const uint8_t yByteMask = (uint8_t)(1 << (y & 7)); + +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + graphics::beginTFTColorRow((int16_t)y); +#endif + + for (uint16_t x = 0; x < displayWidth; x++) { + const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0; + + uint16_t be; +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe); + else + be = isset ? onBe : offBe; +#else + be = isset ? onBe : offBe; +#endif + + const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565 + const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3); + const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2); + const uint8_t b = (uint8_t)((c & 0x1F) << 3); + offscreen->SetPixel(x, y, r, g, b); + } + } + +#if GRAPHICS_TFT_COLORING_ENABLED + // Regions are re-registered every frame by the renderers; clear so they don't accumulate. + graphics::clearTFTColorRegions(); +#endif + + // Present the finished frame at the next vsync; the returned canvas (the previously shown one) + // becomes our next offscreen buffer. + offscreen = matrix->SwapOnVSync(offscreen); +} + +void HUB75Native::sendCommand(uint8_t com) +{ + if (!matrix || !offscreen) + return; + + switch (com) { + case DISPLAYON: + matrix->SetBrightness(brightness); + break; + case DISPLAYOFF: + // rpi-rgb-led-matrix clamps brightness 0 up to 1 and its refresh thread keeps scanning the + // presented canvas, so SetBrightness(0) alone leaves the last frame faintly lit. Present a + // cleared frame so the panel actually goes black while asleep. (matrix->Clear() would only + // clear the RGBMatrix's own buffer, not the FrameCanvas we swap in.) Wake repaints via the + // next display(). + offscreen->Clear(); + offscreen = matrix->SwapOnVSync(offscreen); + break; + default: + // Drop all other SSD1306 init/config commands - not meaningful for the matrix. + break; + } +} + +void HUB75Native::setDisplayBrightness(uint8_t _brightness) +{ + brightness = _brightness; + if (matrix) + matrix->SetBrightness(brightness); +} + +#endif // HAS_HUB75_NATIVE diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 7bb8096ab..8da3d085b 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -201,6 +201,16 @@ void getMacAddr(uint8_t *dmac) } } +bool getDeviceId(uint8_t *deviceId) +{ + if (portduino_config.has_device_id) { + memcpy(deviceId, portduino_config.device_id, sizeof(portduino_config.device_id)); + return true; + } + // Config-supplied id stays preferred: host NIC/BT MACs can be unstable (docker, multi-NIC). + return getMacAddrDeviceId(deviceId); +} + std::string cleanupNameForAutoconf(std::string name) { // Convert spaces -> dashes, lowercase @@ -1007,6 +1017,39 @@ bool loadConfig(const char *configPath) } } } +#if !defined(HAS_HUB75_NATIVE) + if (portduino_config.displayPanel == hub75) { + std::cerr << "HUB75 display panel selected, but this build does not support HUB75" << std::endl; + exit(EXIT_FAILURE); + } +#endif + // HUB75 RGB matrix (Raspberry Pi). Options map onto rgb_matrix::RGBMatrix::Options + + // RuntimeOptions; the library owns its GPIO pins so nothing is read via readGPIOFromYaml. + if (portduino_config.displayPanel == hub75 && yamlConfig["Display"]["HUB75"]) { + YAML::Node hub75 = yamlConfig["Display"]["HUB75"]; + portduino_config.hub75_hardware_mapping = hub75["HardwareMapping"].as("regular"); + portduino_config.hub75_rows = hub75["Rows"].as(64); + portduino_config.hub75_cols = hub75["Cols"].as(64); + portduino_config.hub75_chain_length = hub75["ChainLength"].as(1); + portduino_config.hub75_parallel = hub75["Parallel"].as(1); + portduino_config.hub75_pwm_bits = hub75["PWMBits"].as(11); + portduino_config.hub75_pwm_lsb_nanoseconds = hub75["PWMLSBNanoseconds"].as(130); + portduino_config.hub75_brightness = hub75["Brightness"].as(100); + portduino_config.hub75_scan_mode = hub75["ScanMode"].as(0); + portduino_config.hub75_row_address_type = hub75["RowAddressType"].as(0); + portduino_config.hub75_multiplexing = hub75["Multiplexing"].as(0); + portduino_config.hub75_disable_hardware_pulsing = hub75["DisableHardwarePulsing"].as(false); + portduino_config.hub75_show_refresh_rate = hub75["ShowRefreshRate"].as(false); + portduino_config.hub75_inverse_colors = hub75["InverseColors"].as(false); + portduino_config.hub75_led_rgb_sequence = hub75["RGBSequence"].as("RGB"); + portduino_config.hub75_pixel_mapper_config = hub75["PixelMapper"].as(""); + portduino_config.hub75_panel_type = hub75["PanelType"].as(""); + portduino_config.hub75_limit_refresh_rate_hz = hub75["LimitRefreshRateHz"].as(0); + portduino_config.hub75_gpio_slowdown = hub75["GPIOSlowdown"].as(1); + // The BaseUI framebuffer geometry is the full panel size in pixels. + portduino_config.displayWidth = portduino_config.hub75_cols * portduino_config.hub75_chain_length; + portduino_config.displayHeight = portduino_config.hub75_rows * portduino_config.hub75_parallel; + } } if (yamlConfig["Touchscreen"]) { if (yamlConfig["Touchscreen"]["Module"].as("") == "XPT2046") diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 050aaaf87..b5a47787e 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -34,7 +34,7 @@ inline const std::unordered_map configProducts = { {"RAK6421-13300-S1", "lora-RAK6421-13300-slot1.yaml"}, {"RAK6421-13300-S2", "lora-RAK6421-13300-slot2.yaml"}}; -enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d }; +enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d, hub75 }; enum touchscreen_modules { no_touchscreen, xpt2046, stmpe610, gt911, ft5x06 }; enum portduino_log_level { level_error, level_warn, level_info, level_debug, level_trace }; enum lora_module_enum { @@ -83,7 +83,7 @@ extern struct portduino_config_struct { std::map screen_names = {{x11, "X11"}, {fb, "FB"}, {st7789, "ST7789"}, {st7735, "ST7735"}, {st7735s, "ST7735S"}, {st7796, "ST7796"}, {ili9341, "ILI9341"}, {ili9342, "ILI9342"}, {ili9486, "ILI9486"}, - {ili9488, "ILI9488"}, {hx8357d, "HX8357D"}}; + {ili9488, "ILI9488"}, {hx8357d, "HX8357D"}, {hub75, "HUB75"}}; lora_module_enum lora_module; bool has_rfswitch_table = false; @@ -147,6 +147,29 @@ extern struct portduino_config_struct { pinMapping displayBacklightPWMChannel = {"Display", "BacklightPWMChannel"}; pinMapping displayReset = {"Display", "Reset"}; + // Display -> HUB75 (Raspberry Pi RGB matrix via hzeller/rpi-rgb-led-matrix). + // These mirror rgb_matrix::RGBMatrix::Options + RuntimeOptions; the library owns the GPIO + // pins (chosen by hub75_hardware_mapping), so there are no per-pin mappings here. + std::string hub75_hardware_mapping = "regular"; + int hub75_rows = 64; + int hub75_cols = 64; + int hub75_chain_length = 1; + int hub75_parallel = 1; + int hub75_pwm_bits = 11; + int hub75_pwm_lsb_nanoseconds = 130; + int hub75_brightness = 100; // percent, 1..100 + int hub75_scan_mode = 0; + int hub75_row_address_type = 0; + int hub75_multiplexing = 0; + bool hub75_disable_hardware_pulsing = false; + bool hub75_show_refresh_rate = false; + bool hub75_inverse_colors = false; + std::string hub75_led_rgb_sequence = "RGB"; + std::string hub75_pixel_mapper_config = ""; + std::string hub75_panel_type = ""; + int hub75_limit_refresh_rate_hz = 0; + int hub75_gpio_slowdown = 1; // RuntimeOptions; higher for faster Pis / long cables + // Touchscreen std::string touchscreen_spi_dev = ""; int touchscreen_spi_dev_int = 0; @@ -423,6 +446,30 @@ extern struct portduino_config_struct { out << YAML::Key << "OffsetRotate" << YAML::Value << displayOffsetRotate; + if (displayPanel == hub75) { + out << YAML::Key << "HUB75" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "HardwareMapping" << YAML::Value << hub75_hardware_mapping; + out << YAML::Key << "Rows" << YAML::Value << hub75_rows; + out << YAML::Key << "Cols" << YAML::Value << hub75_cols; + out << YAML::Key << "ChainLength" << YAML::Value << hub75_chain_length; + out << YAML::Key << "Parallel" << YAML::Value << hub75_parallel; + out << YAML::Key << "PWMBits" << YAML::Value << hub75_pwm_bits; + out << YAML::Key << "PWMLSBNanoseconds" << YAML::Value << hub75_pwm_lsb_nanoseconds; + out << YAML::Key << "Brightness" << YAML::Value << hub75_brightness; + out << YAML::Key << "ScanMode" << YAML::Value << hub75_scan_mode; + out << YAML::Key << "RowAddressType" << YAML::Value << hub75_row_address_type; + out << YAML::Key << "Multiplexing" << YAML::Value << hub75_multiplexing; + out << YAML::Key << "DisableHardwarePulsing" << YAML::Value << hub75_disable_hardware_pulsing; + out << YAML::Key << "ShowRefreshRate" << YAML::Value << hub75_show_refresh_rate; + out << YAML::Key << "InverseColors" << YAML::Value << hub75_inverse_colors; + out << YAML::Key << "RGBSequence" << YAML::Value << hub75_led_rgb_sequence; + out << YAML::Key << "PixelMapper" << YAML::Value << hub75_pixel_mapper_config; + out << YAML::Key << "PanelType" << YAML::Value << hub75_panel_type; + out << YAML::Key << "LimitRefreshRateHz" << YAML::Value << hub75_limit_refresh_rate_hz; + out << YAML::Key << "GPIOSlowdown" << YAML::Value << hub75_gpio_slowdown; + out << YAML::EndMap; // HUB75 + } + out << YAML::EndMap; // Display } diff --git a/src/platform/portduino/architecture.h b/src/platform/portduino/architecture.h index b1698a4eb..a35cecfb3 100644 --- a/src/platform/portduino/architecture.h +++ b/src/platform/portduino/architecture.h @@ -30,4 +30,12 @@ #define TB_LEFT (uint8_t) portduino_config.tbLeftPin.pin #define TB_RIGHT (uint8_t) portduino_config.tbRightPin.pin #define TB_PRESS (uint8_t) portduino_config.tbPressPin.pin +#endif + +// HUB75 RGB-matrix panel support on Raspberry Pi (Portduino) turns on automatically when the +// hzeller/rpi-rgb-led-matrix library is installed - its headers land on the include path via +// `pkg-config rgbmatrix` (wired up in variants/native/portduino/platformio.ini). When absent, +// HAS_HUB75_NATIVE stays undefined and the backend compiles out. See src/graphics/HUB75Display.cpp. +#if defined(ARCH_PORTDUINO) && __has_include() +#define HAS_HUB75_NATIVE 1 #endif \ No newline at end of file diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index ee50f4fb1..cb95f8e84 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -1,6 +1,7 @@ #include "HardwareRNG.h" #include "configuration.h" #include "hardware/xosc.h" +#include #include #include #include @@ -98,6 +99,15 @@ void getMacAddr(uint8_t *dmac) dmac[0] = src.id[2]; } +bool getDeviceId(uint8_t *deviceId) +{ + // RP2040/RP2350: 64-bit unique board id (flash serial / OTP) in bytes 0-7 (rest stay zero). + pico_unique_board_id_t board_id; + pico_get_unique_board_id(&board_id); + memcpy(deviceId, board_id.id, sizeof(board_id.id)); + return true; +} + void rp2040Setup() { if (watchdog_caused_reboot()) { diff --git a/src/platform/stm32wl/architecture.h b/src/platform/stm32wl/architecture.h index d269b3fa1..0aa59ff70 100644 --- a/src/platform/stm32wl/architecture.h +++ b/src/platform/stm32wl/architecture.h @@ -16,6 +16,22 @@ #ifndef HAS_WIRE #define HAS_WIRE 1 #endif +#ifndef HAS_LSE +#define HAS_LSE 0 +#endif + +// How long to wait for the LSE 32.768kHz crystal to lock before giving up on hardware RTC support. +// Override in a variant's variant.h if that board's crystal needs longer to stabilize. +#ifndef STM32WL_LSE_TIMEOUT_MS +#define STM32WL_LSE_TIMEOUT_MS 2000 +#endif + +// A variant that sets HAS_LSE must also define STM32WL_LSE_DRIVE - catch that mistake here, not as a confusing +// HAL compile error deep in main-stm32wl.cpp. +#if HAS_LSE && !defined(STM32WL_LSE_DRIVE) +#error \ + "HAS_LSE is set but STM32WL_LSE_DRIVE is not defined - set it in the variant's variant.h to one of RCC_LSEDRIVE_LOW/MEDIUMLOW/MEDIUMHIGH/HIGH" +#endif // // set HW_VENDOR diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 86ef47fea..d429c5662 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,9 +1,22 @@ -#include "RTC.h" #include "configuration.h" +#include "gps/RTC.h" +#include +#include #include #include #include +#if HAS_LSE +#include +#include + +// LSEDRV is a 2-bit RCC_BDCR field where every combination is a legal drive level, so this covers all 4 values. +static_assert((STM32WL_LSE_DRIVE & ~RCC_LSEDRIVE_HIGH) == 0, + "STM32WL_LSE_DRIVE must be one of RCC_LSEDRIVE_LOW/MEDIUMLOW/MEDIUMHIGH/HIGH"); + +static bool stm32wlRtcValid = false; +#endif + // ─── Bootloader redirect ────────────────────────────────────────────────────── // // Why .noinit + constructor instead of TAMP backup registers: @@ -77,7 +90,84 @@ void getMacAddr(uint8_t *dmac) dmac[0] = (uint8_t)(uid2 >> 8); } -void cpuDeepSleep(uint32_t msecToWake) {} +bool getDeviceId(uint8_t *deviceId) +{ + // STM32WL: 96-bit factory silicon UID (words w0..w2, little-endian) in bytes 0-11 (rest stay zero). + uint32_t uid[3] = {HAL_GetUIDw0(), HAL_GetUIDw1(), HAL_GetUIDw2()}; + memcpy(deviceId, uid, sizeof(uid)); + return true; +} + +#if HAS_LSE +// Starts the LSE crystal with a bounded timeout and, if it locks, brings up the STM32 hardware RTC on it. +void stm32wlSetup() +{ + HAL_PWR_EnableBkUpAccess(); + __HAL_RCC_LSEDRIVE_CONFIG(STM32WL_LSE_DRIVE); + __HAL_RCC_LSE_CONFIG(RCC_LSE_ON); + + uint32_t start = millis(); + bool lseReady = false; + while (Throttle::isWithinTimespanMs(start, STM32WL_LSE_TIMEOUT_MS)) { + if (__HAL_RCC_GET_FLAG(RCC_FLAG_LSERDY)) { + lseReady = true; + break; + } + delay(5); + } + + if (lseReady) { + STM32RTC &rtc = STM32RTC::getInstance(); + rtc.setClockSource(STM32RTC::LSE_CLOCK); + rtc.begin(); + stm32wlRtcValid = true; + LowPower.begin(); + LOG_INFO("STM32WL: LSE locked, hardware RTC available"); + } else { + // Don't leave a failed oscillator burning current. + __HAL_RCC_LSE_CONFIG(RCC_LSE_OFF); + LOG_WARN("STM32WL: LSE failed to start within %dms (crystal missing/faulty?) - hardware RTC unavailable", + STM32WL_LSE_TIMEOUT_MS); + } +} + +// True once stm32wlSetup() has confirmed the LSE crystal is locked and the hardware RTC is running. +bool stm32wlRtcAvailable() +{ + return stm32wlRtcValid; +} +#else +void stm32wlSetup() {} +#endif + +void cpuDeepSleep(uint32_t msecToWake) +{ +#if HAS_LSE + if (!stm32wlRtcAvailable()) { + // Hardware can't shutdown, but firmware has already prepared itself for shutdown + // Do not leave the device unresponsive, reset instead + LOG_WARN("STM32WL: hardware RTC failed, cannot deep sleep/shutdown"); + if (Serial) { + Serial.flush(); + Serial.end(); + } + HAL_NVIC_SystemReset(); + } + + if (Serial) { + Serial.flush(); + Serial.end(); + } + + if (msecToWake != portMAX_DELAY) { + LowPower.shutdown(msecToWake); + } else { + LowPower.shutdown(); + } + // RTC wakes from shutdown into MCU reset, so this code should never be reached + HAL_NVIC_SystemReset(); +#endif +} // Hacks to force more code and data out. diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp index e2c4deb05..206bb4ac2 100644 --- a/src/security/EncryptedStorage.cpp +++ b/src/security/EncryptedStorage.cpp @@ -5,10 +5,10 @@ // Common includes - available for all platform implementations #include "EncryptedStorage.h" #include "FSCommon.h" -#include "RTC.h" #include "SPILock.h" #include "SafeFile.h" #include "SecureZero.h" +#include "gps/RTC.h" #include #ifdef ARCH_NRF52 diff --git a/src/target_specific.h b/src/target_specific.h index 7404a3720..d7aba018b 100644 --- a/src/target_specific.h +++ b/src/target_specific.h @@ -7,4 +7,8 @@ // Enable/disable bluetooth. void setBluetoothEnable(bool enable); -void getMacAddr(uint8_t *dmac); \ No newline at end of file +void getMacAddr(uint8_t *dmac); + +// Fill deviceId (a caller-zeroed 16-byte buffer) with a stable silicon/factory id; return true, or +// false (buffer untouched) if none exists here. Writes only leading bytes. Per-arch: src/platform//. +bool getDeviceId(uint8_t *deviceId); diff --git a/src/xmodem.cpp b/src/xmodem.cpp index 447a05ff0..ce7ad8020 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -50,6 +50,7 @@ #include "xmodem.h" #include "SPILock.h" +#include #ifdef FSCom @@ -57,6 +58,24 @@ XModemAdapter xModem; XModemAdapter::XModemAdapter() {} +bool XModemAdapter::isValidFilename(const char *name) +{ + if (!name || name[0] == '\0') + return false; + // Reject any ".." path component. Absolute paths and subdirectories are fine; they stay within + // the filesystem root, so only traversal out of it needs blocking. + for (const char *seg = name; *seg;) { + const char *slash = strchr(seg, '/'); + const size_t len = slash ? (size_t)(slash - seg) : strlen(seg); + if (len == 2 && seg[0] == '.' && seg[1] == '.') + return false; + if (!slash) + break; + seg = slash + 1; + } + return true; +} + /** * Calculates the CRC-16 CCITT checksum of the given buffer. * @@ -122,6 +141,14 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) strncpy(filename, (const char *)xmodemPacket.buffer.bytes, sizeof(filename) - 1); filename[sizeof(filename) - 1] = '\0'; + // The filename is attacker-controlled; refuse a ".." that could write/read/delete + // outside the filesystem root (real host paths on the posix daemon). + if (!isValidFilename(filename)) { + LOG_WARN("XModem: rejecting unsafe filename"); + sendControl(meshtastic_XModem_Control_NAK); + break; + } + if (xmodemPacket.control == meshtastic_XModem_Control_SOH) { // Receive this file and put to Flash // FILE_O_WRITE on Adafruit_LittleFS is append, not truncate - remove first. spiLock->lock(); diff --git a/src/xmodem.h b/src/xmodem.h index 974187820..6119a7b50 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -55,6 +55,11 @@ class XModemAdapter // True while a file transfer is in flight; lets callers avoid racing our `file` handle. bool isBusy() const { return isReceiving || isTransmitting; } + // Reject a transfer filename that could escape the filesystem root via a ".." path component. + // Absolute/subdirectory paths are allowed - PortduinoFS confines them to its mountpoint - so + // this is only about traversal, which matters on the posix daemon where FSCom is the host FS. + static bool isValidFilename(const char *name); + private: bool isReceiving = false; bool isTransmitting = false; diff --git a/test/native-suite-count b/test/native-suite-count index e85087aff..8f92bfdd4 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -31 +35 diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 7dba0677e..c189bee84 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -7,9 +7,16 @@ class AdminModuleTestShim : public AdminModule { public: + using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro) + using AdminModule::handleGetConfig; using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::responseIsSolicited; // request/response pairing gate + using AdminModule::setPassKey; + + // Peek at the reply a handler queued, before drainReply() releases it. + meshtastic_MeshPacket *reply() { return myReply; } // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. void deferSaves() { hasOpenEditTransaction = true; } diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp new file mode 100644 index 000000000..2a4cdda8d --- /dev/null +++ b/test/test_admin_session_repro/test_main.cpp @@ -0,0 +1,448 @@ +// Deterministic reproduction of the admin session-key behavior discussed on PR #10669 +// (ndoo's "Admin message without session_key!" report), plus the remote-vs-local redaction of +// secret material in admin GET responses, plus the request/response pairing that decides which +// admin responses are accepted at all. +// +// Drives the REAL incoming-admin path (AdminModule::handleReceivedProtobuf) with a remote +// (from != 0) PKC-authorized set_owner, exercising the exact checkPassKey/setPassKey gate. +// A local (from == 0) admin bypasses that gate, so the bug only reproduces from != 0. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/Channels.h" +#include "mesh/NodeDB.h" +#include "mesh/mesh-pb-constants.h" +#include "modules/AdminModule.h" +#include "support/AdminModuleTestShim.h" +#include "support/MockMeshService.h" +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us +static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to +static constexpr NodeNum STRANGER_NODE = 0x0D0D0D0D; +static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20}; + +static MockMeshService *mockService = nullptr; +static AdminModuleTestShim *admin = nullptr; + +// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents. +static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen, + meshtastic_AdminMessage &out) +{ + out = meshtastic_AdminMessage_init_zero; + out.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + strncpy(out.set_owner.long_name, newLongName, sizeof(out.set_owner.long_name) - 1); + if (session) { + out.session_passkey.size = sessionLen; + memcpy(out.session_passkey.bytes, session, sessionLen); + } + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = ADMIN_NODE; // REMOTE: this is what makes the session gate apply + mp.channel = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.pki_encrypted = true; // arrived over PKC + mp.public_key.size = 32; + memcpy(mp.public_key.bytes, ADMIN_KEY, 32); // matches config.security.admin_key[0] -> authorized + return mp; +} + +// A get_module_config_response carrying a remote_hardware pin list, as a remote would answer. +// This is the class of message that short-circuited auth: no session passkey, sender need not +// hold an admin key. handleGetModuleConfigResponse() stamps mp.from into the pin table. +static meshtastic_MeshPacket makeModuleConfigResponse(NodeNum from, meshtastic_AdminMessage &out) +{ + out = meshtastic_AdminMessage_init_zero; + out.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag; + out.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; + out.get_module_config_response.payload_variant.remote_hardware.enabled = true; + out.get_module_config_response.payload_variant.remote_hardware.available_pins_count = 1; + out.get_module_config_response.payload_variant.remote_hardware.available_pins[0].gpio_pin = 17; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.to = LOCAL_NODE; + mp.channel = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + return mp; +} + +// One known pin entry, so a response that reaches handleGetModuleConfigResponse rewrites its owner. +static void seedRemoteHardwarePin(NodeNum owner) +{ + devicestate.node_remote_hardware_pins_count = 1; + devicestate.node_remote_hardware_pins[0] = meshtastic_NodeRemoteHardwarePin_init_zero; + devicestate.node_remote_hardware_pins[0].node_num = owner; + devicestate.node_remote_hardware_pins[0].has_pin = true; + devicestate.node_remote_hardware_pins[0].pin.gpio_pin = 4; +} + +// The outgoing request `req` a local client would send to `to`, as MeshService sees it (from == 0, +// ADMIN_APP, payload still plaintext). +static meshtastic_MeshPacket makeOutgoingRequest(NodeNum to, const meshtastic_AdminMessage &req) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0; + p.to = to; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &req); + return p; +} + +static meshtastic_MeshPacket makeOutgoingModuleConfigRequest( + NodeNum to, meshtastic_AdminMessage_ModuleConfigType type = meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) +{ + meshtastic_AdminMessage req = meshtastic_AdminMessage_init_zero; + req.which_payload_variant = meshtastic_AdminMessage_get_module_config_request_tag; + req.get_module_config_request = type; + return makeOutgoingRequest(to, req); +} + +void setUp(void) +{ + mockService = new MockMeshService(); + service = mockService; + admin = new AdminModuleTestShim(); + admin->deferSaves(); // no disk/reboot side effects when a setter is accepted + + if (!nodeDB) + nodeDB = new NodeDB(); + myNodeInfo.my_node_num = LOCAL_NODE; + + config = meshtastic_LocalConfig_init_zero; + // Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate. + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32); + + owner = meshtastic_User_init_zero; + strncpy(owner.long_name, "Original", sizeof(owner.long_name) - 1); + + channels.initDefaults(); + channels.onConfigChanged(); +} + +void tearDown(void) +{ + service = nullptr; + delete mockService; + mockService = nullptr; + delete admin; + admin = nullptr; +} + +// ndoo's report: a setter from a remote node with NO valid session is rejected, and the node's +// expected session key is all-zero because it has minted none since boot. +void test_remote_setter_without_session_is_rejected(void) +{ + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeRemoteSetOwner("Hijacked", nullptr, 0, m); + + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + // Rejected at the session gate -> owner unchanged (this is ndoo's "Admin message without session_key!"). + TEST_ASSERT_EQUAL_STRING("Original", owner.long_name); +} + +// The node's session key is minted only by setPassKey (which runs when it answers an admin GET), +// so before any GET the expected key is all-zero and any presented key mismatches. +void test_expected_session_key_is_zero_before_any_get(void) +{ + uint8_t zero[8] = {0}; + meshtastic_AdminMessage probe = meshtastic_AdminMessage_init_zero; + probe.session_passkey.size = 8; + memcpy(probe.session_passkey.bytes, zero, 8); // even all-zeros must not authorize a state change + // A fresh module has minted no session; a stale/guessed key does not match. + // (checkPassKey also requires size==8 AND session_time freshness.) + uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; // ndoo's presented key + meshtastic_AdminMessage staleMsg = meshtastic_AdminMessage_init_zero; + staleMsg.session_passkey.size = 8; + memcpy(staleMsg.session_passkey.bytes, stale, 8); + TEST_ASSERT_FALSE(admin->checkPassKey(&staleMsg)); // Expected: 00..00 vs Incoming: 29 04 b4 78.. -> reject +} + +// The fix path: once the node answers a GET (setPassKey mints/returns the key), the session gate +// accepts a setter carrying that key. Asserting the gate (checkPassKey) directly is the mechanism; +// driving the full handleSetOwner would need the NodeInfoModule scaffolding, out of scope here. +void test_session_gate_accepts_key_from_a_get_response(void) +{ + // Simulate the node answering an admin GET: setPassKey mints the session and writes it into the response. + meshtastic_AdminMessage getResponse = meshtastic_AdminMessage_init_zero; + admin->setPassKey(&getResponse); + TEST_ASSERT_EQUAL(8, getResponse.session_passkey.size); // node handed the client a session key + + // A setter carrying that exact key passes the gate (would be accepted). + meshtastic_AdminMessage good = meshtastic_AdminMessage_init_zero; + good.session_passkey = getResponse.session_passkey; + TEST_ASSERT_TRUE(admin->checkPassKey(&good)); + + // A setter carrying a stale/guessed key still fails (no session replay). + meshtastic_AdminMessage bad = meshtastic_AdminMessage_init_zero; + bad.session_passkey.size = 8; + uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; + memcpy(bad.session_passkey.bytes, stale, 8); + TEST_ASSERT_FALSE(admin->checkPassKey(&bad)); +} + +// Decode the SecurityConfig out of the get_config response a handler queued in myReply. +static bool decodeSecurityFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_SecurityConfig &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag || + am.get_config_response.which_payload_variant != meshtastic_Config_security_tag) + return false; + out = am.get_config_response.payload_variant.security; + return true; +} + +static meshtastic_MeshPacket makeGetConfigRequest(NodeNum from) +{ + meshtastic_MeshPacket req = meshtastic_MeshPacket_init_zero; + req.from = from; + req.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + req.decoded.want_response = true; + return req; +} + +// The device identity private key must never leave over the air: a SECURITY_CONFIG response to a +// remote request (from != 0, even an authorized admin) carries an empty private_key. +void test_remote_security_config_omits_private_key(void) +{ + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xAB, 32); + + meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG); + + meshtastic_Config_SecurityConfig sec; + TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec)); + TEST_ASSERT_EQUAL_MESSAGE(0, sec.private_key.size, "remote security config must not carry the private key"); + admin->drainReply(); +} + +// Control: the local backup path (from == 0, BLE/USB/TCP) still receives the private key, so the +// redaction above is remote-specific rather than a blanket wipe. +void test_local_security_config_keeps_private_key(void) +{ + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xAB, 32); + + meshtastic_MeshPacket req = makeGetConfigRequest(0); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG); + + meshtastic_Config_SecurityConfig sec; + TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec)); + TEST_ASSERT_EQUAL_MESSAGE(32, sec.private_key.size, "local backup must still receive the private key"); + TEST_ASSERT_EACH_EQUAL_HEX8(0xAB, sec.private_key.bytes, 32); + admin->drainReply(); +} + +// An admin response carries no session passkey and its sender is not an admin-key holder, so a +// request we sent is the only thing vouching for it. A get_module_config_response from a node we +// never queried is not. +static constexpr pb_size_t MODULE_CONFIG_RESPONSE = meshtastic_AdminMessage_get_module_config_response_tag; +static constexpr pb_size_t REMOTE_HW_TAG = meshtastic_ModuleConfig_remote_hardware_tag; // makeModuleConfigResponse subtype + +void test_unsolicited_response_is_not_solicited(void) +{ + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a response nobody asked for must not be accepted"); +} + +// Control: once the client has sent that node a request, its response is accepted. Without this, +// the test above would also pass if responseIsSolicited() simply always said no. +void test_response_after_our_request_is_solicited(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "the answer to our own request must be accepted"); +} + +// A request to one remote does not vouch for a different remote's response. +void test_request_to_one_node_does_not_admit_another(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // answered by someone else + + TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// A response only answers its own request type: a get_owner_request does not admit a +// get_module_config_response from the same node. +void test_response_variant_must_match_request(void) +{ + meshtastic_AdminMessage owner_req = meshtastic_AdminMessage_init_zero; + owner_req.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, owner_req)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // wrong type for the request + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a get_owner request must not admit a get_module_config response"); + // ...but the response it actually asked for is still accepted from the same slot. + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, meshtastic_AdminMessage_get_owner_response_tag, 0)); +} + +// Regression: each request keeps its own pinned key. A later unpinned request to the same node +// must not relax the PKC pin of an earlier one (the old shared-slot model cleared it). +void test_pinned_request_keeps_its_key_after_an_unpinned_request(void) +{ + uint8_t key[32]; + memset(key, 0xC1, 32); + + // A PKC-pinned get_config request to STRANGER (pins `key`). + meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero; + cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag; + meshtastic_MeshPacket pinned = makeOutgoingRequest(STRANGER_NODE, cfg); + pinned.pki_encrypted = true; + pinned.public_key.size = 32; + memcpy(pinned.public_key.bytes, key, 32); + admin->noteOutgoingAdminRequest(pinned); + + // A later, unpinned get_owner request to the same node. + meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero; + own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, own)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket resp = makeModuleConfigResponse(STRANGER_NODE, m); // from set; pki off by default + + // A plaintext get_config_response is still rejected: the config request was pinned. + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0), + "an unpinned request must not relax an earlier request's key pin"); + // Over PKC with the pinned key it is accepted. + resp.pki_encrypted = true; + resp.public_key.size = 32; + memcpy(resp.public_key.bytes, key, 32); + TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0)); + // The unpinned get_owner_response is accepted in plaintext (its request carried no pin). + resp.pki_encrypted = false; + resp.public_key.size = 0; + TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_owner_response_tag, 0)); +} + +// A remote_hardware response must answer a request for that exact subtype, not just any module +// config - else an MQTT-config request could authorize a pin-table update. +void test_module_config_subtype_must_match(void) +{ + admin->noteOutgoingAdminRequest( + makeOutgoingModuleConfigRequest(STRANGER_NODE, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // remote_hardware subtype + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a non-remote-hardware request must not admit a remote_hardware response"); + + // Control: a request for the matching subtype does admit it. + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// A matched request is consumed, so a node cannot replay a state-mutating response within the window. +void test_response_is_consumed_no_replay(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a second copy of an already-answered response must be rejected"); +} + +// Only requests arm the gate: sending a setter to a node must not make it a trusted responder. +void test_outgoing_setter_does_not_admit_responses(void) +{ + meshtastic_AdminMessage setter = meshtastic_AdminMessage_init_zero; + setter.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, setter)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// End to end through the real handler: an unsolicited get_module_config_response must not reach +// handleGetModuleConfigResponse, so the remote_hardware pin table keeps its owner. +void test_unsolicited_response_does_not_poison_pins(void) +{ + seedRemoteHardwarePin(QUERIED_NODE); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(QUERIED_NODE, devicestate.node_remote_hardware_pins[0].node_num, + "unsolicited response must not rewrite the pin owner"); +} + +// Control: once we have requested it, the same response is handled and updates the pin table. This +// proves the assertion above is the auth gate firing, not the handler being dead. (It also exercises +// the dispatch tag fixed in this change - without it, the handler never runs for either node.) +void test_solicited_response_updates_pins(void) +{ + seedRemoteHardwarePin(QUERIED_NODE); + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(STRANGER_NODE, devicestate.node_remote_hardware_pins[0].node_num, + "a response we asked for must reach the handler"); +} + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_remote_setter_without_session_is_rejected); + RUN_TEST(test_expected_session_key_is_zero_before_any_get); + RUN_TEST(test_session_gate_accepts_key_from_a_get_response); + RUN_TEST(test_remote_security_config_omits_private_key); + RUN_TEST(test_local_security_config_keeps_private_key); + RUN_TEST(test_unsolicited_response_is_not_solicited); + RUN_TEST(test_response_after_our_request_is_solicited); + RUN_TEST(test_request_to_one_node_does_not_admit_another); + RUN_TEST(test_response_variant_must_match_request); + RUN_TEST(test_pinned_request_keeps_its_key_after_an_unpinned_request); + RUN_TEST(test_module_config_subtype_must_match); + RUN_TEST(test_response_is_consumed_no_replay); + RUN_TEST(test_outgoing_setter_does_not_admit_responses); + RUN_TEST(test_unsolicited_response_does_not_poison_pins); + RUN_TEST(test_solicited_response_updates_pins); +#endif + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_breakout/test_main.cpp b/test/test_breakout/test_main.cpp new file mode 100644 index 000000000..1ca2da5e2 --- /dev/null +++ b/test/test_breakout/test_main.cpp @@ -0,0 +1,103 @@ +#include "TestUtil.h" +#include "modules/games/Breakout.h" +#include + +// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on +// a straight-up serve, and the ball staying within the board. No device globals or display stack. + +static const uint32_t kSeed = 0xC0FFEEu; + +void test_reset_initialState() +{ + BreakoutGame game; + game.reset(kSeed); + TEST_ASSERT_TRUE(game.isPlaying()); + TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives()); + TEST_ASSERT_EQUAL_UINT8(1, game.level()); + TEST_ASSERT_EQUAL_UINT32(0, game.score()); + // Every brick present at the start. + TEST_ASSERT_EQUAL_UINT16(static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS, game.bricksRemaining()); + // Paddle centred, ball above it and inside the board. + TEST_ASSERT_EQUAL_INT16((BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W) / 2, game.paddleX()); + TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W); + TEST_ASSERT_TRUE(game.ballY() >= 0 && game.ballY() < BreakoutGame::BOARD_H); +} + +void test_paddle_clampsToEdges() +{ + BreakoutGame game; + game.reset(kSeed); + for (int i = 0; i < 100; i++) + game.moveLeft(); + TEST_ASSERT_EQUAL_INT16(0, game.paddleX()); + for (int i = 0; i < 100; i++) + game.moveRight(); + TEST_ASSERT_EQUAL_INT16(BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W, game.paddleX()); +} + +void test_serve_clearsABrickAndScores() +{ + BreakoutGame game; + game.reset(kSeed); + // The ball serves upward from just above the paddle straight into the brick field; within a + // few dozen steps it must clear at least one brick and score. + for (int i = 0; + i < 60 && game.bricksRemaining() == static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++) + game.step(); + TEST_ASSERT_TRUE(game.bricksRemaining() < static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS); + TEST_ASSERT_TRUE(game.score() > 0); +} + +void test_ball_staysInBounds() +{ + BreakoutGame game; + game.reset(kSeed); + // Drive the paddle to follow the ball so the game keeps going, and check the ball never leaves + // the board horizontally across a long run. + for (int i = 0; i < 500 && game.isPlaying(); i++) { + if (game.ballX() < game.paddleX()) + game.moveLeft(); + else + game.moveRight(); + game.step(); + TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W); + TEST_ASSERT_TRUE(game.ballY() >= 0); + } +} + +void test_deadGame_stepIsNoOp() +{ + BreakoutGame game; + game.reset(kSeed); + // Park the paddle in a corner and never move it; the ball is eventually lost every life. + game.moveLeft(); + for (int i = 0; i < 20000 && game.isPlaying(); i++) { + for (int j = 0; j < 40; j++) // hold the paddle pinned left + game.moveLeft(); + game.step(); + } + TEST_ASSERT_FALSE(game.isPlaying()); + const uint32_t scoreBefore = game.score(); + TEST_ASSERT_FALSE(game.step()); // stays dead, no further change + TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score()); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_reset_initialState); + RUN_TEST(test_paddle_clampsToEdges); + RUN_TEST(test_serve_clearsABrickAndScores); + RUN_TEST(test_ball_staysInBounds); + RUN_TEST(test_deadGame_stepIsNoOp); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_chirpy/test_main.cpp b/test/test_chirpy/test_main.cpp new file mode 100644 index 000000000..487b2830f --- /dev/null +++ b/test/test_chirpy/test_main.cpp @@ -0,0 +1,100 @@ +#include "TestUtil.h" +#include "modules/games/ChirpyRunner.h" +#include + +// Pure-logic tests for ChirpyRunnerGame: initial state, jump lifts Chirpy off the ground, +// obstacles spawn and a collision ends the run, and a dead game is inert. No device globals. + +static const uint32_t kSeed = 0xC0FFEEu; + +void test_reset_initialState() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + TEST_ASSERT_TRUE(game.isPlaying()); + TEST_ASSERT_EQUAL_UINT32(0, game.score()); + TEST_ASSERT_TRUE(game.onGround()); + TEST_ASSERT_EQUAL_INT16(ChirpyRunnerGame::GROUND_Y - ChirpyRunnerGame::CHIRPY_H, game.chirpyY()); +} + +void test_jump_liftsChirpy() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + const int16_t groundY = game.chirpyY(); + game.jump(); + game.step(); + TEST_ASSERT_FALSE(game.onGround()); + TEST_ASSERT_TRUE(game.chirpyY() < groundY); // rose above the ground rest position +} + +void test_jump_ignoredWhileAirborne() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + game.jump(); + game.step(); + const int16_t yAfterFirst = game.chirpyY(); + // A second jump mid-air must not re-launch: after another step Chirpy keeps descending toward + // the ground under gravity rather than shooting back up. + game.jump(); + game.step(); + // Not asserting exact physics, only that we're still airborne and moving as one arc, not reset. + TEST_ASSERT_FALSE(game.onGround()); + (void)yAfterFirst; +} + +void test_obstacleSpawnsAndCollisionEndsGame() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + // One step spawns the first obstacle. + game.step(); + bool any = false; + for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++) + any = any || game.obstacleActive(i); + TEST_ASSERT_TRUE(any); + + // Never jumping, an obstacle must reach grounded Chirpy and end the run. + int steps = 0; + while (game.isPlaying() && steps < 2000) { + game.step(); + steps++; + } + TEST_ASSERT_FALSE(game.isPlaying()); +} + +void test_deadGame_stepIsNoOp() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + int steps = 0; + while (game.isPlaying() && steps < 2000) { + game.step(); + steps++; + } + TEST_ASSERT_FALSE(game.isPlaying()); + const uint32_t scoreBefore = game.score(); + TEST_ASSERT_FALSE(game.step()); // stays dead, no further change + TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score()); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_reset_initialState); + RUN_TEST(test_jump_liftsChirpy); + RUN_TEST(test_jump_ignoredWhileAirborne); + RUN_TEST(test_obstacleSpawnsAndCollisionEndsGame); + RUN_TEST(test_deadGame_stepIsNoOp); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index a67ba920e..88d7f0259 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -132,6 +132,34 @@ static void test_migration_carriesRoleAndProtectedIntoWarm(void) TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); } +// The signer bit is learned from verified traffic, not NodeInfo, so it must survive a warm +// round trip. The plain node is the control: re-admission restores it, it doesn't invent it. +static void test_migration_carriesSignerBitThroughWarm(void) +{ + db->seedSelf(); + const NodeNum signerNum = 2000 + 3; + const NodeNum plainNum = 2000 + 4; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted + for (int i = 1; i <= extra; i++) + db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true, /*withKey=*/true); + nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(db->getMeshNode(signerNum))); + + db->runDemote(); + + // Both are out of the hot store and held in the warm tier. + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); + TEST_ASSERT_NULL(db->getMeshNode(plainNum)); + + const meshtastic_NodeInfoLite *back = db->getOrCreateMeshNode(signerNum); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(back), "signer bit must survive a warm-tier round trip"); + + const meshtastic_NodeInfoLite *plainBack = db->getOrCreateMeshNode(plainNum); + TEST_ASSERT_NOT_NULL(plainBack); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasXeddsaSigned(plainBack), "re-admission must not invent the signer bit"); +} + // Favourite handling: a favourite is never the eviction victim, even when it is // the oldest node in a full hot store. static void test_eviction_preservesFavorite(void) @@ -197,6 +225,39 @@ static void test_protectedCap_refusesBeyondLimit(void) TEST_ASSERT_TRUE(db->setProtectedFlag(already, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)); } +// removeNodeByNum() compacts survivors down and clears the slots that leaves free. A full +// store with no matching node frees none, so there is nothing past the last node to clear. +static void test_removeNodeByNum_absentNodeOnFullDb(void) +{ + db->seedSelf(); + for (int i = 1; i < MAX_NUM_NODES; i++) // fill to MAX_NUM_NODES total (incl. self) + db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true); + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); + + db->removeNodeByNum(0xDEADBEEF); // absent; ASan flags a write past the last slot + + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); // nothing removed + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x0BADF00D)); // self intact + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 1)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // last slot intact +} + +// Control for the above: a matching node on a full store is still removed, the survivors +// compact down, and the freed tail slot is cleared. +static void test_removeNodeByNum_presentNodeOnFullDb(void) +{ + db->seedSelf(); + for (int i = 1; i < MAX_NUM_NODES; i++) + db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true); + + db->removeNodeByNum(8000 + 5); + + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 1, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(8000 + 5)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 4)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // survivors kept +} + NDB_TEST_ENTRY void setup() { initializeTestEnvironment(); @@ -206,9 +267,12 @@ NDB_TEST_ENTRY void setup() UNITY_BEGIN(); RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf); RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); + RUN_TEST(test_migration_carriesSignerBitThroughWarm); RUN_TEST(test_eviction_preservesFavorite); RUN_TEST(test_ignored_survivesEvictionAndCleanup); RUN_TEST(test_protectedCap_refusesBeyondLimit); + RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb); + RUN_TEST(test_removeNodeByNum_presentNodeOnFullDb); exit(UNITY_END()); } NDB_TEST_ENTRY void loop() {} diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 707d71619..f0eb60473 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -148,6 +148,58 @@ static bool signedEncodingFits(const meshtastic_Data *d) return encodedDataSize(©) + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN; } +// Append a length-delimited field whose tag this build's Data schema does not define, as a sender +// on a newer schema would emit. nanopb skips unknown fields at decode, so these bytes count toward +// the raw wire size but not the decoded struct. Returns the number of bytes appended. +static size_t appendUnknownField(uint8_t *dst, size_t dstLen, size_t contentLen) +{ + constexpr uint32_t UNKNOWN_FIELD_NUMBER = 100; // not a field of meshtastic_Data + std::vector content(contentLen, 0x77); + pb_ostream_t stream = pb_ostream_from_buffer(dst, dstLen); + TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, UNKNOWN_FIELD_NUMBER)); + TEST_ASSERT_TRUE(pb_encode_string(&stream, content.data(), content.size())); + return stream.bytes_written; +} + +// Channel-encrypt raw Data bytes into a packet, exactly as perhapsEncode's non-PKI path does. +// Used to inject wire bytes perhapsEncode would never produce (it only encodes p->decoded). +static void encryptAsChannelPacket(meshtastic_MeshPacket *p, uint8_t *wire, size_t size) +{ + const int16_t hash = channels.setActiveByIndex(0); + TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel"); + crypto->encryptPacket(getFrom(p), p->id, size, wire); + memcpy(p->encrypted.bytes, wire, size); + p->encrypted.size = size; + p->channel = hash; // on the wire the channel field carries the hash, not the index + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; +} + +// Build A10's frame: an unsigned broadcast carrying a POSITION payload plus unknown fields, sized +// so the raw wire length exceeds the signature-fit threshold while the decoded fields stay under +// it. Channel-encrypted like a normal sender. The asserts pin that split, which is what makes A10 +// and A11 meaningful. +static meshtastic_MeshPacket makeBroadcastWithUnknownFields() +{ + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + + uint8_t wire[MAX_LORA_PAYLOAD_LEN + 1]; + const size_t base = pb_encode_to_bytes(wire, sizeof(wire), &meshtastic_Data_msg, &p.decoded); + TEST_ASSERT_GREATER_THAN_MESSAGE(0, base, "failed to encode the base Data"); + const size_t raw = base + appendUnknownField(wire + base, sizeof(wire) - base, 160); + + // The decoded fields fit a signature, so a sender that signs would have signed this Data. + TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, base + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH, + "decoded fields must fit a signature, else the test is vacuous"); + // The unknown fields put the raw size over that threshold, so the two sizings disagree here. + TEST_ASSERT_GREATER_THAN_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH, + "unknown fields must push the raw size past the fit threshold"); + // The frame is still one a radio could actually send. + TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + MESHTASTIC_HEADER_LENGTH, "frame must still fit a LoRa frame"); + + encryptAsChannelPacket(&p, wire, raw); + return p; +} + // --------------------------------------------------------------------------- // Unity lifecycle // --------------------------------------------------------------------------- @@ -324,6 +376,41 @@ void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void) TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); } +// A10: unknown fields must not sway the downgrade decision. A sender on a newer schema can include +// Data fields this build doesn't define; nanopb skips them, so they grow the frame without changing +// what decodes. The decision sizes p->decoded, keeping it on the fields the sender encoded, so an +// unsigned broadcast from a known signer is dropped whether or not unknown fields came along. +void test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before + + meshtastic_MeshPacket p = makeBroadcastWithUnknownFields(); + + TEST_ASSERT_EQUAL_MESSAGE(DECODE_FAILURE, perhapsDecode(&p), + "unsigned broadcast from a signer must be dropped despite unknown fields"); +} + +// A11: A10's control - the same frame from a node we've never seen sign is accepted and still +// delivers its payload, so A10's DECODE_FAILURE is the downgrade drop and not a decode failure +// caused by the unknown fields. +void test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted(void) +{ + mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + + meshtastic_MeshPacket p = makeBroadcastWithUnknownFields(); + const size_t rawSize = p.encrypted.size; // read before decode: encrypted/decoded share a union + + TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&p), "frame from a non-signer must still decode"); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_PortNum_POSITION_APP, p.decoded.portnum, "unknown fields must not disturb the portnum"); + TEST_ASSERT_EQUAL_MESSAGE(SMALL_PAYLOAD, p.decoded.payload.size, "payload must survive the unknown fields"); + TEST_ASSERT_FALSE(p.xeddsa_signed); + + // The unknown fields are gone from the decoded struct: the gap the sizing basis has to account for. + TEST_ASSERT_LESS_THAN_MESSAGE(rawSize, encodedDataSize(&p.decoded), + "unknown fields must drop at decode, leaving decoded size < raw"); +} + // =========================================================================== // Group B - send-side signing policy (perhapsEncode) // =========================================================================== @@ -418,7 +505,7 @@ void test_B5_preset_signature_on_local_packet_cleared(void) // B6: the exact-fit gate tracks Data *shape*, not just payload size. A tapback-style broadcast // (want_response + reply_id + emoji) carries extra wire bytes that shift the fit boundary; the // sweep proves no dead band exists for that shape either, and - once the signer bit is learned - -// that the receiver's rawSize-driven downgrade predicate stays symmetric for it too. Window +// that the receiver's downgrade predicate stays symmetric for it too. Window // straddles this shape's boundary; capped at 200 so even the unsigned rich encoding stays well // inside the frame (at n=221 it first hits the pre-existing, signing-unrelated TOO_LARGE). void test_B6_rich_shape_sweep_no_deadband(void) @@ -558,7 +645,7 @@ void test_D1_signature_field_overhead_exact(void) // =========================================================================== // Already-decoded packets never reach perhapsDecode's crypto path (it early-returns), so // plaintext-MQTT downlink applies this policy function directly at ingress (MQTT.cpp). These -// tests drive it the same way: decoded packets, encodedDataSize = 0 (canonical sizing). +// tests drive it the same way: decoded packets, sized from p->decoded exactly as the RF path is. // End-to-end MQTT wiring is covered in test_mqtt. // E1: unsigned small broadcast from a known signer -> dropped (downgrade protection holds on @@ -616,8 +703,8 @@ void test_E4_decoded_bad_signature_dropped(void) TEST_ASSERT_FALSE(p.xeddsa_signed); } -// E5: unsigned oversized broadcast from a signer -> accepted (canonical sizing exempts packets -// whose signed encoding wouldn't fit, mirroring the RF-path rawSize rule). +// E5: unsigned oversized broadcast from a signer -> accepted (packets whose signed encoding +// wouldn't fit are exempt, identically to the RF path: both size p->decoded). void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void) { mockNodeDB->addNode(REMOTE_NODE); @@ -700,6 +787,8 @@ void setup() RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_A8_unsigned_deadband_broadcast_from_signer_accepted); RUN_TEST(test_A9_unsigned_boundary_broadcast_from_signer_still_dropped); + RUN_TEST(test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped); + RUN_TEST(test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted); printf("\n=== Group B: send-side signing policy ===\n"); RUN_TEST(test_B1_local_broadcast_is_signed); diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp new file mode 100644 index 000000000..c03652244 --- /dev/null +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -0,0 +1,223 @@ +// Tests for the admin-key fallback in Router::perhapsDecode: a PKI unicast from an unknown sender is +// tried against each configured admin_key; on success the packet decodes and the key is persisted to +// NodeDB. Drives the real crypto + NodeDB path with packets encrypted exactly as an admin radio would. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +// The whole feature is compiled out when PKI is excluded. +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" // MESHTASTIC_PKC_OVERHEAD +#include "mesh/Router.h" +#include +#include +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; // us (the receiver) +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // an authorized admin, absent from our NodeDB +static constexpr uint32_t PKT_ID = 0x12345678; + +// MockNodeDB - inject nodes with controlled public keys (meshNodes/numMeshNodes are public on NodeDB). +// Mirrors test/test_packet_signing. +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + void setPublicKey(NodeNum num, const uint8_t *pubKey) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + n->public_key.size = 32; + memcpy(n->public_key.bytes, pubKey, 32); + } + + std::vector testNodes; +}; + +static MockNodeDB *mockNodeDB = nullptr; + +// Keypairs, regenerated fresh each test in setUp(). "our" == the receiver, "admin" == the sender. +static uint8_t ourPub[32], ourPriv[32]; +static uint8_t adminPub[32], adminPriv[32]; + +// Store a 32-byte key into config.security.admin_key[slot]. +static void setAdminKey(int slot, const uint8_t *key32) +{ + config.security.admin_key[slot].size = 32; + memcpy(config.security.admin_key[slot].bytes, key32, 32); + if (slot + 1 > (int)config.security.admin_key_count) + config.security.admin_key_count = slot + 1; +} + +// Build a PKI-encrypted unicast from `from` to us, encrypted with `senderPriv` against our public key, +// leaving the engine holding our private key afterwards (as during receive) so perhapsDecode can decrypt. +static meshtastic_MeshPacket makePkiPacket(NodeNum from, meshtastic_PortNum port, size_t payloadLen, const uint8_t *senderPriv) +{ + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = port; + data.payload.size = payloadLen; + for (size_t i = 0; i < payloadLen; i++) + data.payload.bytes[i] = (uint8_t)(i & 0xff); + + uint8_t plain[meshtastic_Constants_DATA_PAYLOAD_LEN]; + size_t plainLen = pb_encode_to_bytes(plain, sizeof(plain), &meshtastic_Data_msg, &data); + TEST_ASSERT_TRUE_MESSAGE(plainLen > 0, "pb_encode_to_bytes failed in test setup"); + + meshtastic_NodeInfoLite_public_key_t ourPubStruct; + ourPubStruct.size = 32; + memcpy(ourPubStruct.bytes, ourPub, 32); + + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = LOCAL_NODE; + p.id = PKT_ID; + p.channel = 0; // PKI packets carry channel hash 0 + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + + // Encrypt AS the sender: shared secret = DH(senderPriv, ourPub). + crypto->setDHPrivateKey(const_cast(senderPriv)); + bool ok = crypto->encryptCurve25519(p.to, p.from, ourPubStruct, p.id, plainLen, plain, p.encrypted.bytes); + TEST_ASSERT_TRUE_MESSAGE(ok, "encryptCurve25519 failed in test setup"); + p.encrypted.size = plainLen + MESHTASTIC_PKC_OVERHEAD; + + // Restore the engine to our private key, as it is when receiving. + crypto->setDHPrivateKey(ourPriv); + return p; +} + +// Assert the packet decoded via PKI and that we learned `expectedKey` for its sender. +static void assertDecodedAndLearned(meshtastic_MeshPacket *p, const uint8_t *expectedKey) +{ + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p->which_payload_variant); + TEST_ASSERT_TRUE(p->pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p->decoded.portnum); + TEST_ASSERT_EQUAL(32, p->public_key.size); + TEST_ASSERT_EQUAL_MEMORY(expectedKey, p->public_key.bytes, 32); + + meshtastic_NodeInfoLite *learned = mockNodeDB->getMeshNode(p->from); + TEST_ASSERT_NOT_NULL_MESSAGE(learned, "sender should have been created in NodeDB"); + TEST_ASSERT_EQUAL_MESSAGE(32, learned->public_key.size, "sender key should have been persisted"); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expectedKey, learned->public_key.bytes, 32, "persisted key mismatch"); +} + +void setUp(void) +{ + // Construct the mock FIRST: the NodeDB ctor can reload persisted host state and repopulate globals. + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; + + config = meshtastic_LocalConfig_init_zero; + owner = meshtastic_User_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; // drives isToUs()/getFrom() + + channels.initDefaults(); + channels.onConfigChanged(); + + // Fresh keypairs for us and the admin (independent, valid Curve25519 pairs). + crypto->generateKeyPair(ourPub, ourPriv); + crypto->generateKeyPair(adminPub, adminPriv); + + // perhapsDecode's PKI gate requires that we have our own key (getMeshNode(p->to)->public_key). + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, ourPub); + + // During receive the engine holds our private key. + crypto->setDHPrivateKey(ourPriv); +} + +void tearDown(void) +{ + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; +} + +// Admin key in slot 0: a DM from an unknown sender decrypts via the fallback, and the key is persisted. +void test_admin_key_slot0_decrypts_and_persists(void) +{ + setAdminKey(0, adminPub); + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "precondition: sender is unknown to us"); + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + + assertDecodedAndLearned(&p, adminPub); +} + +// The loop scans every admin slot, not just [0]: a key provisioned only in slot 2 still works. +void test_admin_key_slot2_only_decrypts(void) +{ + setAdminKey(2, adminPub); // slots 0 and 1 left empty + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + + assertDecodedAndLearned(&p, adminPub); +} + +// No admin key configured + unknown sender: nothing decrypts, and we must NOT invent a key for anyone. +void test_no_admin_key_unknown_sender_not_decoded(void) +{ + // config (incl. admin_key) is zeroed by setUp(); ADMIN_NODE is absent from NodeDB. + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "must not learn a key when nothing decrypted"); +} + +// A configured admin key that is NOT the sender's must fail authentication (no bogus key learned). +void test_wrong_admin_key_does_not_decode(void) +{ + uint8_t otherPub[32], otherPriv[32]; + crypto->generateKeyPair(otherPub, otherPriv); // unrelated key + crypto->setDHPrivateKey(ourPriv); // restore receive key (generateKeyPair changed it) + setAdminKey(0, otherPub); // admin slot holds a key that did NOT encrypt the packet + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE)); +} + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + UNITY_BEGIN(); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_admin_key_slot0_decrypts_and_persists); + RUN_TEST(test_admin_key_slot2_only_decrypts); + RUN_TEST(test_no_admin_key_unknown_sender_not_decoded); + RUN_TEST(test_wrong_admin_key_does_not_decode); +#endif + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index fbb937952..5f5569752 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -214,7 +214,8 @@ static void test_cryptoKeyIsPublic_invalidKeyIsNotPublic() // Pre-fix, latitude_i = INT32_MAX made latLongToUTM read latBands[36] on a 21-char string // (stack-buffer-overflow at GeoCoord.cpp:128, an AddressSanitizer abort); extreme longitude produced // a negative UTM zone feeding the MGRS letter tables. The fix clamps the zone/band/col/row indices. -// This exercises the fix under the coverage env's ASan. +// This exercises the fix under the coverage env's ASan. Each representation is computed lazily, +// so its getter must be called explicitly here to exercise its conversion path. static void test_geocoord_extreme_coords_no_oob() { const int32_t vals[] = {INT32_MIN, INT32_MAX, INT32_MIN + 1, INT32_MAX - 1, 0, 1, -1, 900000000, -900000000, // +/-90 deg @@ -223,7 +224,13 @@ static void test_geocoord_extreme_coords_no_oob() const size_t n = sizeof(vals) / sizeof(vals[0]); for (size_t i = 0; i < n; i++) for (size_t j = 0; j < n; j++) { - GeoCoord g(vals[i], vals[j], 0); // ctor -> setCoords() -> UTM/MGRS/OSGR/OLC + GeoCoord g(vals[i], vals[j], 0); // ctor -> setCoords() -> DMS only + // Force UTM/MGRS/OSGR/OLC computation (each lazy on first getter access). + g.getUTMZone(); + g.getMGRSZone(); + g.getOSGRE100k(); + char olcCode[OLC_CODE_LEN + 1]; + g.getOLCCode(olcCode); // Surviving every extreme pair (no ASan fault) means the index clamps hold. TEST_ASSERT_EQUAL_INT32(vals[i], g.getLatitude()); } diff --git a/test/test_snake/test_main.cpp b/test/test_snake/test_main.cpp new file mode 100644 index 000000000..8668e66a5 --- /dev/null +++ b/test/test_snake/test_main.cpp @@ -0,0 +1,180 @@ +#include "TestUtil.h" +#include "modules/games/Snake.h" +#include + +// Pure-logic tests for SnakeGame: ring-buffer advance, reversal rejection, wall/self collision, +// growth on eat, and food-placement validity. No device globals or display stack required. + +static const uint32_t kSeed = 0xC0FFEEu; + +// Count how many board cells the snake currently occupies (cross-check for len()). +static uint16_t countOccupied(const SnakeGame &game) +{ + uint16_t n = 0; + for (uint8_t y = 0; y < SnakeGame::GRID_H; y++) + for (uint8_t x = 0; x < SnakeGame::GRID_W; x++) + if (game.occupied(x, y)) + n++; + return n; +} + +static void test_reset_initialState() +{ + SnakeGame game; + game.reset(kSeed); + + TEST_ASSERT_TRUE(game.isPlaying()); + TEST_ASSERT_FALSE(game.isWon()); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); + TEST_ASSERT_EQUAL_UINT32(0u, game.score()); + TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_RIGHT, game.direction()); + + // Head spawns at board centre; the whole test file relies on this anchor. + SnakeGame::Cell head = game.head(); + TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_W / 2, head.x); + TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_H / 2, head.y); + + // Exactly START_LEN cells occupied, and the head is one of them. + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game)); + TEST_ASSERT_TRUE(game.occupied(head.x, head.y)); +} + +static void test_food_isValidAndOffBody() +{ + SnakeGame game; + game.reset(kSeed); + SnakeGame::Cell food = game.food(); + TEST_ASSERT_TRUE(food.x < SnakeGame::GRID_W); + TEST_ASSERT_TRUE(food.y < SnakeGame::GRID_H); + TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); // food never spawns on the snake +} + +static void test_setDirection_rejectsReversal() +{ + SnakeGame game; + game.reset(kSeed); // heading right + + TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); // 180 reversal -> rejected + TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_UP)); // perpendicular -> ok + TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_RIGHT)); // same as committed dir -> ok (no-op) + + // A double-input within one tick can't chain into a reversal: after latching UP, LEFT is + // still checked against the committed RIGHT and rejected, so the neck stays safe. + game.setDirection(SnakeGame::DIR_UP); + TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); +} + +static void test_step_movesAndTailFollows() +{ + SnakeGame game; + game.reset(kSeed); + SnakeGame::Cell head = game.head(); + game.placeFoodAt(0, 0); // corner, off the snake -> guaranteed non-eating step + + TEST_ASSERT_TRUE(game.step()); + SnakeGame::Cell newHead = game.head(); + TEST_ASSERT_EQUAL_UINT8(head.x + 1, newHead.x); // moved one cell right + TEST_ASSERT_EQUAL_UINT8(head.y, newHead.y); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); // length unchanged when not eating + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game)); + TEST_ASSERT_EQUAL_UINT32(0u, game.score()); +} + +static void test_eat_growsAndScores() +{ + SnakeGame game; + game.reset(kSeed); + SnakeGame::Cell head = game.head(); + game.placeFoodAt(head.x + 1, head.y); // food directly ahead + + TEST_ASSERT_TRUE(game.step()); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, game.length()); // grew by one + TEST_ASSERT_EQUAL_UINT32(1u, game.score()); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, countOccupied(game)); + + // A fresh food was placed and is not on the snake. + SnakeGame::Cell food = game.food(); + TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); +} + +static void test_wallCollision_endsGame() +{ + SnakeGame game; + game.reset(kSeed); + game.placeFoodAt(0, 0); + game.setDirection(SnakeGame::DIR_UP); // head is at mid-height; drive straight up into the wall + + bool alive = true; + int guard = 0; + while (alive && guard++ < SnakeGame::GRID_H + 4) { + game.placeFoodAt(0, 0); // keep food out of the way each tick + alive = game.step(); + } + TEST_ASSERT_FALSE(alive); + TEST_ASSERT_FALSE(game.isPlaying()); +} + +static void test_selfCollision_endsGame() +{ + SnakeGame game; + game.reset(kSeed); + TEST_ASSERT_EQUAL_UINT8(16, game.head().x); // anchor the deterministic path below + TEST_ASSERT_EQUAL_UINT8(6, game.head().y); + + // Grow to length 5 along a straight horizontal line (cells (14..18, 6)). + game.placeFoodAt(17, 6); + TEST_ASSERT_TRUE(game.step()); + game.placeFoodAt(18, 6); + TEST_ASSERT_TRUE(game.step()); + TEST_ASSERT_EQUAL_UINT16(5, game.length()); + + // Curl back on itself: DOWN, LEFT, then UP re-enters an occupied body cell. + game.setDirection(SnakeGame::DIR_DOWN); + game.placeFoodAt(0, 0); + TEST_ASSERT_TRUE(game.step()); + game.setDirection(SnakeGame::DIR_LEFT); + game.placeFoodAt(0, 0); + TEST_ASSERT_TRUE(game.step()); + game.setDirection(SnakeGame::DIR_UP); + game.placeFoodAt(0, 0); + TEST_ASSERT_FALSE(game.step()); // bites its own body + TEST_ASSERT_FALSE(game.isPlaying()); +} + +static void test_deadGame_stepIsNoOp() +{ + SnakeGame game; + game.reset(kSeed); + game.setDirection(SnakeGame::DIR_UP); + for (int i = 0; i < SnakeGame::GRID_H + 4; i++) { + game.placeFoodAt(0, 0); + game.step(); + } + TEST_ASSERT_FALSE(game.isPlaying()); + uint32_t scoreBefore = game.score(); + TEST_ASSERT_FALSE(game.step()); // stays dead, no state change + TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score()); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_reset_initialState); + RUN_TEST(test_food_isValidAndOffBody); + RUN_TEST(test_setDirection_rejectsReversal); + RUN_TEST(test_step_movesAndTailFollows); + RUN_TEST(test_eat_growsAndScores); + RUN_TEST(test_wallCollision_endsGame); + RUN_TEST(test_selfCollision_endsGame); + RUN_TEST(test_deadGame_stepIsNoOp); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 84613cf32..5a657d441 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -408,6 +408,73 @@ void test_serial_console_suppresses_raw_output_in_protobuf_mode() TEST_ASSERT_TRUE(emptyAfterProtobuf); } +// Build a phone->radio ADMIN_APP packet carrying `admin`, with an arbitrary wire `from`. +static meshtastic_MeshPacket makeAdminPacket(NodeNum from, const meshtastic_AdminMessage &admin) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &admin); + return p; +} + +// The lockdown admin gate must decide on the connection's authorization, not the wire `from`. A +// client that sets from != 0 previously skipped the gate, so an unauthorized connection could run +// admin. classifyLocalAdminPacket ignores `from`, so the same spoofed packet is still dropped. +static void test_lockdown_admin_gate_ignores_wire_from(void) +{ + meshtastic_AdminMessage setter = meshtastic_AdminMessage_init_zero; + setter.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + meshtastic_MeshPacket spoofed = makeAdminPacket(0x12345678, setter); // from != 0, the bypass + + meshtastic_AdminMessage out; + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::DropUnauthorized, + (int)PhoneAPI::classifyLocalAdminPacket(spoofed, /*adminAuthorized=*/false, out), + "unauthorized admin with from != 0 must still be dropped"); + // Control: an authorized connection's identical packet passes through. + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::AuthorizedPassThrough, + (int)PhoneAPI::classifyLocalAdminPacket(spoofed, /*adminAuthorized=*/true, out), + "authorized admin must not be dropped"); + + // lockdown_auth is the authentication itself, so it is delivered inline regardless of from/auth. + meshtastic_AdminMessage la = meshtastic_AdminMessage_init_zero; + la.which_payload_variant = meshtastic_AdminMessage_lockdown_auth_tag; + meshtastic_MeshPacket authPkt = makeAdminPacket(0x99, la); + TEST_ASSERT_EQUAL((int)PhoneAPI::LocalAdminGate::LockdownAuth, + (int)PhoneAPI::classifyLocalAdminPacket(authPkt, /*adminAuthorized=*/false, out)); + + // A non-admin packet is outside the gate entirely. + meshtastic_MeshPacket text = meshtastic_MeshPacket_init_zero; + text.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + text.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL((int)PhoneAPI::LocalAdminGate::NotAdmin, + (int)PhoneAPI::classifyLocalAdminPacket(text, /*adminAuthorized=*/false, out)); +} + +// An ADMIN_APP packet whose payload is not a decodable AdminMessage must fall through to the +// normal reject path (NotAdmin), never be acted on as an admin command. The authorized control +// proves the decode-failure check runs before the auth branch, so it can't pass for the wrong reason. +static void test_lockdown_admin_gate_rejects_undecodable_admin(void) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + // Length-delimited field (tag 0x0A) claiming 16 bytes with none following: pb_decode fails. + p.decoded.payload.bytes[0] = 0x0A; + p.decoded.payload.bytes[1] = 0x10; + p.decoded.payload.size = 2; + + meshtastic_AdminMessage out; + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::NotAdmin, + (int)PhoneAPI::classifyLocalAdminPacket(p, /*adminAuthorized=*/false, out), + "undecodable ADMIN_APP payload must fall through to the reject path"); + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::NotAdmin, + (int)PhoneAPI::classifyLocalAdminPacket(p, /*adminAuthorized=*/true, out), + "undecodable ADMIN_APP payload must not pass through even when authorized"); +} + /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} /// Unity per-test teardown; fixtures clean themselves up. @@ -427,6 +494,8 @@ void setup() RUN_TEST(test_stream_api_short_write_reports_failure_without_flush); RUN_TEST(test_stream_api_finishes_pending_before_advancing_phone_api); RUN_TEST(test_stream_api_gates_logs_and_marks_them_best_effort); + RUN_TEST(test_lockdown_admin_gate_ignores_wire_from); + RUN_TEST(test_lockdown_admin_gate_rejects_undecodable_admin); // usingProtobufs intentionally has no reset path, so this must run last. RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode); exit(UNITY_END()); diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index c203438f4..f64ea814f 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -110,6 +110,21 @@ class MockNodeDB : public NodeDB clearCachedNode(); } + // Seed a hot-store node that has been learned as an XEdDSA signer, with a known name, so the + // identity-update gate can be exercised. Distinct from setCachedNode() so a separate cached + // node (the direct-response target) can coexist. + void setSignerHotNode(NodeNum n, const char *longName) + { + if (meshNodes->size() < 2) + meshNodes->resize(2); + (*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero; + (*meshNodes)[1].num = n; + nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_USER_MASK, true); + strncpy((*meshNodes)[1].long_name, longName, sizeof((*meshNodes)[1].long_name) - 1); + numMeshNodes = 2; + } + private: bool hasCachedNode = false; NodeNum cachedNodeNum = 0; @@ -589,6 +604,45 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); } +/** + * A unicast NodeInfo request is never signed, so a known signer's identity claim on the + * direct-response path is unauthenticated. It must not overwrite the stored name (spoofing + * defense), while the direct response itself still goes out. The non-signer case + * (test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo) is the control: it still learns. + */ +static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk"); + request.to = kTargetNode; + request.decoded.want_response = true; + request.id = 0x0A0B0C0D; + request.hop_start = 3; + request.hop_limit = 3; + request.xeddsa_signed = false; // unicast: never signed + + ProcessMessage result = module.handleReceived(request); + + // The response still went out (the request was consumed from cache)... + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits); + // ...but the known signer's stored name was not overwritten by the unauthenticated claim. + const meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + TEST_ASSERT_NOT_NULL(requestor); + TEST_ASSERT_EQUAL_STRING("victim-real", requestor->long_name); +} + /** * Verify client role only answers direct (0-hop) NodeInfo requests. * Important so clients do not answer relayed requests outside intended scope. @@ -1715,6 +1769,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops); RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache); RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo); + RUN_TEST(test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity); RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); #if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); diff --git a/test/test_warm_store/test_main.cpp b/test/test_warm_store/test_main.cpp index e26d9ee45..389ddc560 100644 --- a/test/test_warm_store/test_main.cpp +++ b/test/test_warm_store/test_main.cpp @@ -116,15 +116,15 @@ void test_ws_keyedCandidate_evictsOldestKeylessFirst() // Fill with keyed entries except two keyless ones in the middle for (size_t i = 0; i < ws.capacity(); i++) { const bool keyless = (i == 5 || i == 10); - // Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives - // quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10). - TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key)); + // One quantum apart (shift by WARM_META_BITS) so LRU order survives quantisation: + // keyless i=10 is oldest (50), i=5 next (60), keyed all older (10). + TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << WARM_META_BITS, keyless ? NULL : key)); } // Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50), // even though every keyed entry is older (ts=10) uint8_t k2[32]; makeKey(k2, 0x43); - TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2)); + TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << WARM_META_BITS, k2)); TEST_ASSERT_FALSE(ws.contains(0x1000 + 10)); TEST_ASSERT_TRUE(ws.contains(0x1000 + 5)); TEST_ASSERT_TRUE(ws.contains(0x8888)); @@ -171,6 +171,31 @@ void test_ws_meta_roundTrip() TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); } +// The signer flag rides the same packed word as role/protected, so it must survive a +// round trip without disturbing them (or the quantised timestamp). +void test_ws_signer_roundTrip() +{ + WarmNodeStore ws; + uint8_t key[32]; + makeKey(key, 0x78); + TEST_ASSERT_TRUE(ws.absorb(0x710, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true)); + TEST_ASSERT_TRUE(ws.absorb(0x711, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/false)); + + WarmNodeEntry e; + TEST_ASSERT_TRUE(ws.take(0x710, e)); + TEST_ASSERT_TRUE_MESSAGE(warmSignerOf(e), "signer flag must round trip"); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); // and must not disturb its neighbours in the word + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); + TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e)); + + // Control: without the flag the same entry reads back clear, so the accessor is + // reporting the stored bit rather than always-true. + TEST_ASSERT_TRUE(ws.take(0x711, e)); + TEST_ASSERT_FALSE(warmSignerOf(e)); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); +} + void test_ws_remove_and_clear() { WarmNodeStore ws; @@ -257,6 +282,63 @@ void test_ws_v1_migration_discardsLastHeard() b.saveIfDirty(); } +// A v2 (WRM2) warm.dat used bit 6 as a timestamp bit, so loading one must not read it as a +// signer, while role/protected/time carry over. File backend only. +void test_ws_v2_migration_clearsSignerBit() +{ + WarmNodeStore a; + uint8_t key[32], got[32]; + makeKey(key, 0x67); + // signer=true sets bit 6, standing in for a v2 record whose timestamp had it set. + a.absorb(0x910, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true); + if (!a.saveIfDirty()) { + TEST_IGNORE_MESSAGE("Filesystem not available in this test environment"); + return; + } + + // Flip the 4-byte header magic to v2 ("WRM2"). CRC covers only the entry bytes, so + // patching the header keeps it valid. + std::vector buf; + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ); + if (!f) { + TEST_IGNORE_MESSAGE("warm.dat not readable in this environment"); + return; + } + buf.resize(f.size()); + const size_t got = f.read(buf.data(), buf.size()); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(buf.size(), got, "short read patching warm.dat"); + } + TEST_ASSERT_TRUE(buf.size() >= 4); + const uint32_t v2magic = 0x324D5257u; // "WRM2" + memcpy(buf.data(), &v2magic, sizeof(v2magic)); + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE); + TEST_ASSERT_TRUE((bool)f); + const size_t wrote = f.write(buf.data(), buf.size()); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(buf.size(), wrote, "short write patching warm.dat"); + } + + WarmNodeStore b; + b.load(); + TEST_ASSERT_TRUE(b.contains(0x910)); + TEST_ASSERT_TRUE(b.copyKey(0x910, got)); + TEST_ASSERT_EQUAL_MEMORY(key, got, 32); + + WarmNodeEntry e; + TEST_ASSERT_TRUE(b.take(0x910, e)); + TEST_ASSERT_FALSE_MESSAGE(warmSignerOf(e), "a v2 timestamp bit must not read as a signer"); + // Unlike v1, v2 kept role/protected/time in place, so they survive the migration. + TEST_ASSERT_EQUAL(123456u & WARM_TIME_MASK, warmTimeOf(e)); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); + + b.clear(); + b.saveIfDirty(); +} + // Shrink safety: a warm.dat snapshot recording more entries than this build's // WARM_NODE_COUNT (e.g. written before a per-platform tier reduction) must be // rejected cleanly at the header check - load() starts empty instead of reading @@ -314,9 +396,11 @@ WS_TEST_ENTRY void setup() RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst); RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless); RUN_TEST(test_ws_meta_roundTrip); + RUN_TEST(test_ws_signer_roundTrip); RUN_TEST(test_ws_remove_and_clear); RUN_TEST(test_ws_persistence_roundTrip); RUN_TEST(test_ws_v1_migration_discardsLastHeard); + RUN_TEST(test_ws_v2_migration_clearsSignerBit); RUN_TEST(test_ws_load_rejectsOversizedSnapshot); exit(UNITY_END()); } diff --git a/test/test_xmodem/test_main.cpp b/test/test_xmodem/test_main.cpp new file mode 100644 index 000000000..816c78e9c --- /dev/null +++ b/test/test_xmodem/test_main.cpp @@ -0,0 +1,55 @@ +// Tests for XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer +// handler (src/xmodem.cpp). The filename in a SOH/STX control frame is attacker-controlled and +// drives FSCom open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." +// component could escape the mountpoint. Absolute/subdirectory paths must still be accepted. +#include "TestUtil.h" +#include "xmodem.h" +#include + +void setUp(void) {} +void tearDown(void) {} + +#ifdef FSCom + +void test_xmodem_rejects_dotdot_traversal(void) +{ + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("../secret")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/prefs/../../etc/passwd")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("a/../b")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir/..")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/..")); +} + +void test_xmodem_rejects_empty(void) +{ + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(nullptr)); +} + +void test_xmodem_allows_legit_paths(void) +{ + // The file manager transfers absolute, subdirectoried paths from the manifest. + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("/prefs/config.proto")); + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("firmware.bin")); + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/sub/file.txt")); + // ".." only inside a name (not a whole component) is a valid filename, not traversal. + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("my..file")); + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("...")); +} + +#endif // FSCom + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); +#ifdef FSCom + RUN_TEST(test_xmodem_rejects_dotdot_traversal); + RUN_TEST(test_xmodem_rejects_empty); + RUN_TEST(test_xmodem_allows_legit_paths); +#endif + exit(UNITY_END()); +} + +void loop() {} diff --git a/userPrefs.jsonc b/userPrefs.jsonc index a132eb906..66ee623c6 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -37,6 +37,10 @@ // "USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL": "900", // Device telemetry update interval in seconds // "USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL": "900", // Environment telemetry update interval in seconds // "USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED": "1", // Force BMP280/sensor reads + LoRa broadcast on first boot + // "USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED": "1", // Environment telemetry enable on screen + // "USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL": "900", // AQ telemetry update interval in seconds + // "USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED": "1", // AQ telemetry enable + // "USERPREFS_CONFIG_AQ_SCREEN_ENABLED": "1", // AQ telemetry enable on screen // "USERPREFS_LORACONFIG_CHANNEL_NUM": "31", // "USERPREFS_LORACONFIG_TX_POWER": "0", // "USERPREFS_LORACONFIG_MODEM_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST", diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index d68cc5e2a..0da8e94db 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -12,4 +12,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index aa667d57b..23346bdab 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -35,4 +35,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 2114d965e..401c3868d 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index c5c405c1f..1e8efd5b9 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -51,7 +51,7 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534 hideakitai/TCA9534@0.1.1 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [crowpanel_small_esp32s3_base] ; 2.4, 2.8, 3.5 inch extends = crowpanel_base diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index e21ff6a30..2844d0aeb 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -134,6 +134,6 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini index 4198df454..d545b55dd 100644 --- a/variants/esp32s3/heltec_v4_r8/platformio.ini +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -140,6 +140,6 @@ build_flags = lib_deps = ${heltec_v4_r8_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 65003a061..3db282214 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -26,4 +26,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index 4613a8e92..575109abe 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index d8afbfe94..fc3935be3 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -21,4 +21,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index fe5086562..0b111126b 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index f47d7990d..f7aabc126 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 956c43015..3cf7bf8dd 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index aba1695ce..4983fc5c5 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index ec70148a8..e1f6814e2 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/t5s3_epaper/platformio.ini b/variants/esp32s3/t5s3_epaper/platformio.ini index 9dbb6d32d..781b382b5 100644 --- a/variants/esp32s3/t5s3_epaper/platformio.ini +++ b/variants/esp32s3/t5s3_epaper/platformio.ini @@ -1,7 +1,7 @@ [t5s3_epaper_base] extends = esp32s3_base board = t5-epaper-s3 -board_build.partition = default_16MB.csv +board_build.partitions = default_16MB.csv board_check = true upload_protocol = esptool build_flags = -fno-strict-aliasing @@ -30,8 +30,8 @@ lib_deps = [env:t5s3_epaper_inkhud] extends = t5s3_epaper_base, inkhud -board_level = extra # inkhud port is incomplete -board_check = false +board_level = extra +board_check = true build_flags = ${t5s3_epaper_base.build_flags} ${inkhud.build_flags} diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 5f243342b..607526ba3 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index 2764a27e8..20ef9e30e 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -22,7 +22,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -48,7 +48,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index e5bc7ead8..48317db5e 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -36,7 +36,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/esp32s3/visualizer-hub75/platformio.ini b/variants/esp32s3/visualizer-hub75/platformio.ini new file mode 100644 index 000000000..fb321da26 --- /dev/null +++ b/variants/esp32s3/visualizer-hub75/platformio.ini @@ -0,0 +1,20 @@ +; Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) + 128x64 HUB75 matrix + Wio-SX1262 - DIY +[env:visualizer-hub75] +extends = esp32s3_base +board = esp32-s3-devkitc-1 +board_check = true +board_build.partitions = default_16MB.csv +board_level = extra +board_upload.flash_size = 16MB ; N16R8 -> 16MB flash +board_build.arduino.memory_type = qio_opi ; N16R8 -> 8MB octal (OPI) PSRAM +build_flags = + ${esp32s3_base.build_flags} + -D VISUALIZER_HUB75 + -D BOARD_HAS_PSRAM + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -I variants/esp32s3/visualizer-hub75 +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=github-tags depName=ESP32-HUB75-MatrixPanel-I2S-DMA packageName=mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA + https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA/archive/refs/tags/3.0.14.zip diff --git a/variants/esp32s3/visualizer-hub75/variant.h b/variants/esp32s3/visualizer-hub75/variant.h new file mode 100644 index 000000000..0481024e3 --- /dev/null +++ b/variants/esp32s3/visualizer-hub75/variant.h @@ -0,0 +1,66 @@ +// Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) driving a 128x64 HUB75 RGB matrix +// via the seengreat "RGB Matrix Adapter Board (E)" Rev. 2.2 (V2.x), plus a +// Wio-SX1262 (for XIAO, Seeed p/n 6379) LoRa header board. + +#define BUTTON_PIN 0 + +#define I2C_SDA 41 +#define I2C_SCL 42 + +// --- Display: HUB75 via seengreat Shield (V2.x, hard-wired) ----------------- +#define HAS_SPI_TFT 1 // enable the color BaseUI path (TFTColorRegions/theming) +#define DISPLAY_FORCE_SMALL_FONTS // 128x64 panel: keep OLED-sized fonts, not big TFT fonts +#define USE_HUB75 // select HUB75Display in Screen.cpp + +#define TFT_WIDTH 128 +#define TFT_HEIGHT 64 +#define HUB75_BRIGHTNESS_DEFAULT 180 + +// RGB data lines. +// This panel is BGR: R and B are swapped per half vs. the nominal seengreat +// mapping (R1<->B1, R2<->B2), verified at bring-up. Software-only fix; the +// shield board is unchanged. G / address / control lines stay as routed. +#define HUB75_R1 17 +#define HUB75_G1 8 +#define HUB75_B1 18 +#define HUB75_R2 15 +#define HUB75_G2 1 +#define HUB75_B2 16 +// Row-address lines (1/32 scan -> A..E) +#define HUB75_A 7 +#define HUB75_B 48 +#define HUB75_C 6 +#define HUB75_D 47 +#define HUB75_E 2 +// Control lines +#define HUB75_CLK 5 +#define HUB75_LAT 21 +#define HUB75_OE 4 + +// --- Radio: Wio-SX1262 for XIAO (header board, Seeed p/n 6379) --------------- +#define USE_SX1262 + +#define LORA_SCK 12 +#define LORA_MISO 13 +#define LORA_MOSI 11 +#define LORA_CS 10 +#define LORA_RESET 9 +#define LORA_DIO1 40 + +#define SX126X_CS LORA_CS +#define SX126X_SCK LORA_SCK +#define SX126X_MOSI LORA_MOSI +#define SX126X_MISO LORA_MISO +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 14 +#define SX126X_RESET LORA_RESET + +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define SX126X_RXEN 39 +#define SX126X_TXEN RADIOLIB_NC + +#define SX126X_MAX_POWER 22 + +#define LED_HEARTBEAT 38 diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 98cbaf6c3..044f64166 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -26,7 +26,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main https://github.com/pine64/libch341-spi-userspace/archive/2e5ff751d0c39667993df672cb683740ed5c9394.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library @@ -56,6 +56,8 @@ build_flags_common = -luv -std=gnu17 -std=gnu++17 + -DBASEUI_HAS_GAMES=1 + -DMAX_TFT_COLOR_REGIONS=64 build_flags = ${portduino_base.build_flags_common} diff --git a/variants/native/portduino/HUB75Native.h b/variants/native/portduino/HUB75Native.h new file mode 100644 index 000000000..3b9e2728e --- /dev/null +++ b/variants/native/portduino/HUB75Native.h @@ -0,0 +1,57 @@ +#pragma once + +#include "configuration.h" + +#if defined(HAS_HUB75_NATIVE) + +#include + +#ifndef HUB75_BRIGHTNESS_DEFAULT +#define HUB75_BRIGHTNESS_DEFAULT 100 // rpi-rgb-led-matrix brightness is a 1..100 percentage +#endif + +namespace rgb_matrix +{ +class RGBMatrix; +class FrameCanvas; +} // namespace rgb_matrix + +/** + * Native (Raspberry Pi / Portduino) HUB75 RGB-matrix display backend. + * + * Drives the panel with hzeller/rpi-rgb-led-matrix. Like the ESP32 HUB75Display it plugs into the + * BaseUI color path by subclassing OLEDDisplay and expanding the mono framebuffer into RGB via the + * shared TFTColorRegions/TFTPalette helpers. It is a separate class (not shared with the ESP32 + * driver) so no rpi-rgb-led-matrix code lives in src/graphics/. All wiring/tuning comes from + * config.yaml (portduino_config.hub75_*); the library owns the GPIO pins. + * + * Bodies live in src/platform/portduino/HUB75Native.cpp (compiled native-only). + */ +class HUB75Native : public OLEDDisplay +{ + public: + HUB75Native(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus); + ~HUB75Native(); + + // Redraw the whole panel from the BaseUI buffer (region-aware color expansion). + void display() override; + + void setDisplayBrightness(uint8_t brightness); + + protected: + int getBufferOffset(void) override { return 0; } + + void sendCommand(uint8_t com) override; + + bool connect() override; + + private: + rgb_matrix::RGBMatrix *matrix = nullptr; + // Offscreen buffer for tear-free updates: display() draws here, then SwapOnVSync() atomically + // presents it. Without this, writing into the live canvas while the refresh thread scans it + // tears (worse when display() is contended during packet TX/RX). + rgb_matrix::FrameCanvas *offscreen = nullptr; + uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT; // 1..100 +}; + +#endif // HAS_HUB75_NATIVE diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index e71386b67..5694a0d3b 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -20,6 +20,11 @@ build_flags = ${native_base.build_flags} !pkg-config --libs openssl --silence-errors || : !pkg-config --cflags --libs sdl2 --silence-errors || : !pkg-config --cflags --libs libbsd-overlay --silence-errors || : + ; Optional HUB75 RGB-matrix support on Raspberry Pi via hzeller/rpi-rgb-led-matrix. + ; When the lib is installed (provides rgbmatrix.pc), these add its include/link flags and + ; __has_include() enables HAS_HUB75_NATIVE (see configuration.h). Absent -> no-op. + !pkg-config --cflags rgbmatrix --silence-errors || : + !pkg-config --libs rgbmatrix --silence-errors || : [env:native-tft] extends = native_base diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h index 714c58c24..9c9bbebd4 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h @@ -13,7 +13,7 @@ // DIO5 -> RFSW0_V1 // DIO6 -> RFSW1_V2 // DIO7 -> not connected on E80 module - note that GNSS and Wifi scanning are not possible. - +#ifdef USE_LR1121 static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_LR11X0_DIO7, RADIOLIB_NC, RADIOLIB_NC}; @@ -30,13 +30,14 @@ static const Module::RfSwitchMode_t rfswitch_table[] = { END_OF_MODE_TABLE, // clang-format on }; +#endif // LR2021 RF switch matrix following the standard Semtech / Seeed T1000-E reference topology. // DIO5 -> antenna path select (HIGH = sub-GHz LF) // DIO6 -> TX enable / HP PA select // DIO7 -> not connected (no GNSS on LR2021) // DIO8 -> RF front-end power enable - +#ifdef USE_LR2021 static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_LR2021_DIO5, RADIOLIB_LR2021_DIO6, RADIOLIB_LR2021_DIO7, RADIOLIB_LR2021_DIO8, RADIOLIB_NC}; @@ -51,3 +52,4 @@ static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { END_OF_MODE_TABLE, // clang-format on }; +#endif diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 82178d321..5b580f684 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -121,9 +121,9 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define USE_RF95 #define USE_SX1268 #define USE_LR1121 +#define USE_LR2021 // RF95 CONFIG - #define LORA_DIO0 (0 + 29) // P0.29 BUSY #define LORA_DIO1 (0 + 10) // P0.10 IRQ #define LORA_RESET (0 + 9) // P0.09 NRST @@ -156,7 +156,7 @@ NRF52 PRO MICRO PIN ASSIGNMENT #endif // LR2021 -#define USE_LR2021 +#ifdef USE_LR2021 #define LR2021_IRQ_PIN (0 + 10) // P0.10 IRQ #define LR2021_NRESET_PIN LORA_RESET // P0.09 NRST #define LR2021_BUSY_PIN (0 + 29) // P0.29 BUSY @@ -164,6 +164,7 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define LR2021_DIO3_TCXO_VOLTAGE 1.8 #define LR2021_DIO_AS_RF_SWITCH #define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10 +#endif // #define SX126X_MAX_POWER 8 set this if using a high-power board! diff --git a/variants/nrf52840/rak3401_1watt/platformio.ini b/variants/nrf52840/rak3401_1watt/platformio.ini index 889a17ed6..0220042d1 100644 --- a/variants/nrf52840/rak3401_1watt/platformio.ini +++ b/variants/nrf52840/rak3401_1watt/platformio.ini @@ -36,6 +36,19 @@ lib_deps = # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip +; RAK10729 WisMesh Repeater Mini V2 HP (RAK3401 + RAK19007 + RAK13302 + solar battery) +[env:rak_wismesh_repeater_mini_hp] +extends = env:rak3401-1watt +custom_meshtastic_display_name = RAK WisMesh Repeater Mini V2 HP +custom_meshtastic_images = rak3401.svg +build_flags = + ${env:rak3401-1watt.build_flags} + -D WISMESH_REPEATER_MINI_HP + -DMESHTASTIC_EXCLUDE_GPS=1 + -DLOW_VDD_SYSTEMOFF_DELAY_MS=5000 + -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 + -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 + ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ; Note: as of 6/2013 the serial/bootloader based programming takes approximately 30 seconds ;upload_protocol = jlink diff --git a/variants/nrf52840/rak3401_1watt/variant.cpp b/variants/nrf52840/rak3401_1watt/variant.cpp index a035fbaf0..188b747e8 100644 --- a/variants/nrf52840/rak3401_1watt/variant.cpp +++ b/variants/nrf52840/rak3401_1watt/variant.cpp @@ -23,6 +23,13 @@ #include "wiring_constants.h" #include "wiring_digital.h" +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +#include "Arduino.h" +#include "FreeRTOS.h" +#include "power/PowerHAL.h" +#include "sleep.h" +#endif + const uint32_t g_ADigitalPinMap[] = { // P0 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, @@ -40,3 +47,39 @@ void initVariant() pinMode(PIN_3V3_EN, OUTPUT); digitalWrite(PIN_3V3_EN, HIGH); } + +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +void variant_nrf52LoopHook(void) +{ + // If VDD stays unsafe for a while (brownout), force System OFF. + // Skip when VBUS present to allow recovery while USB-powered. + if (!powerHAL_isVBUSConnected()) { + // Rate-limit VDD safety checks: powerHAL_isPowerLevelSafe() calls getVDDVoltage() each time. + static constexpr uint32_t POWER_LEVEL_CHECK_INTERVAL_MS = 100; + static uint32_t last_vdd_check_ms = 0; + static bool last_power_level_safe = true; + + const uint32_t now = millis(); + if (last_vdd_check_ms == 0 || (uint32_t)(now - last_vdd_check_ms) >= POWER_LEVEL_CHECK_INTERVAL_MS) { + last_vdd_check_ms = now; + last_power_level_safe = powerHAL_isPowerLevelSafe(); + } + + // Do not use millis()==0 as a sentinel: at boot, millis() may be 0 while VDD is unsafe. + static bool low_vdd_timer_armed = false; + static uint32_t low_vdd_since_ms = 0; + + if (!last_power_level_safe) { + if (!low_vdd_timer_armed) { + low_vdd_since_ms = now; + low_vdd_timer_armed = true; + } + if ((uint32_t)(now - low_vdd_since_ms) >= (uint32_t)LOW_VDD_SYSTEMOFF_DELAY_MS) { + cpuDeepSleep(portMAX_DELAY); + } + } else { + low_vdd_timer_armed = false; + } + } +} +#endif diff --git a/variants/nrf52840/rak3401_1watt/variant.h b/variants/nrf52840/rak3401_1watt/variant.h index a154e6a41..329ec21e4 100644 --- a/variants/nrf52840/rak3401_1watt/variant.h +++ b/variants/nrf52840/rak3401_1watt/variant.h @@ -209,6 +209,13 @@ static const uint8_t SCK = PIN_SPI_SCK; #define AREF_VOLTAGE 3.0 #define VBAT_AR_INTERNAL AR_INTERNAL_3_0 #define ADC_MULTIPLIER 1.73 +#if defined(LOW_VDD_SYSTEMOFF_DELAY_MS) +#if !defined(OCV_ARRAY) +#define OCV_ARRAY 4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990 +#endif +#define BATTERY_LPCOMP_INPUT NRF_LPCOMP_INPUT_3 +#define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_5_8 // VDD=3.3V AIN3=5/8*VDD=2.06V VBAT=1.66*AIN3=3.41V +#endif #define RAK_4631 1 diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index 179d73e92..d0b776b3b 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -44,6 +44,33 @@ lib_deps = # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip +; RAK10728 WisMesh Repeater Mini V2 (RAK4631 + RAK19003 + solar battery + GPS module) +[env:rak_wismesh_repeater_mini] +extends = env:rak4631 +custom_meshtastic_display_name = RAK WisMesh Repeater Mini V2 +custom_meshtastic_images = rak4631.svg +build_flags = + ${env:rak4631.build_flags} + -D WISMESH_REPEATER_MINI + -DLOW_VDD_SYSTEMOFF_DELAY_MS=5000 + -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 + -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 + +; RAK WisMesh Pocket V3 - rak4631 pin map + OLED; no ethernet (unlike env:rak4631) +[env:rak_wismesh_pocket] +extends = env:rak4631 +custom_meshtastic_display_name = RAK WisMesh Pocket V3 +custom_meshtastic_images = rak4631.svg +build_flags = + ${env:rak4631.build_flags} + -D WISMESH_POCKET + -DLOW_VDD_SYSTEMOFF_DELAY_MS=5000 + -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 + -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 +build_src_filter = ${env:rak4631.build_src_filter} + - + - + ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ; Note: as of 6/2013 the serial/bootloader based programming takes approximately 30 seconds ;upload_protocol = jlink diff --git a/variants/nrf52840/rak4631/variant.cpp b/variants/nrf52840/rak4631/variant.cpp index a035fbaf0..eed47eb2c 100644 --- a/variants/nrf52840/rak4631/variant.cpp +++ b/variants/nrf52840/rak4631/variant.cpp @@ -23,6 +23,13 @@ #include "wiring_constants.h" #include "wiring_digital.h" +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +#include "Arduino.h" +#include "FreeRTOS.h" +#include "power/PowerHAL.h" +#include "sleep.h" +#endif + const uint32_t g_ADigitalPinMap[] = { // P0 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, @@ -40,3 +47,48 @@ void initVariant() pinMode(PIN_3V3_EN, OUTPUT); digitalWrite(PIN_3V3_EN, HIGH); } + +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +static bool lowVddSystemOff = false; // Set to true when VDD is unsafe for a while (brownout), forcing System OFF. +// Strong override; must be noinline (see main-nrf52.cpp weak default). +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() +{ + return lowVddSystemOff; +} + +// Hook called each nrf52Loop(); e.g. for low-VDD System OFF. +void variant_nrf52LoopHook(void) +{ + // If VDD stays unsafe for a while (brownout), force System OFF. + // Skip when VBUS present to allow recovery while USB-powered. + if (!powerHAL_isVBUSConnected()) { + // Rate-limit VDD safety checks: powerHAL_isPowerLevelSafe() calls getVDDVoltage() each time. + static constexpr uint32_t POWER_LEVEL_CHECK_INTERVAL_MS = 100; + static uint32_t last_vdd_check_ms = 0; + static bool last_power_level_safe = true; + + const uint32_t now = millis(); + if (last_vdd_check_ms == 0 || (uint32_t)(now - last_vdd_check_ms) >= POWER_LEVEL_CHECK_INTERVAL_MS) { + last_vdd_check_ms = now; + last_power_level_safe = powerHAL_isPowerLevelSafe(); + } + + // Do not use millis()==0 as a sentinel: at boot, millis() may be 0 while VDD is unsafe. + static bool low_vdd_timer_armed = false; + static uint32_t low_vdd_since_ms = 0; + + if (!last_power_level_safe) { + if (!low_vdd_timer_armed) { + low_vdd_since_ms = now; + low_vdd_timer_armed = true; + } + if ((uint32_t)(now - low_vdd_since_ms) >= (uint32_t)LOW_VDD_SYSTEMOFF_DELAY_MS) { + lowVddSystemOff = true; + cpuDeepSleep(portMAX_DELAY); + } + } else { + low_vdd_timer_armed = false; + } + } +} +#endif diff --git a/variants/nrf52840/rak4631/variant.h b/variants/nrf52840/rak4631/variant.h index 8ea272a15..bdd797836 100644 --- a/variants/nrf52840/rak4631/variant.h +++ b/variants/nrf52840/rak4631/variant.h @@ -231,6 +231,11 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG #define PIN_3V3_EN (34) #define WB_IO2 PIN_3V3_EN +#if defined(WISMESH_POCKET) +// Pocket v3: P1.02 (GPIO 34) = GPS_PWR_EN +#define PIN_GPS_EN PIN_3V3_EN +#endif + // RAK1910 GPS module // If using the wisblock GPS module and pluged into Port A on WisBlock base // IO1 is hooked to PPS (pin 12 on header) = gpio 17 @@ -253,9 +258,11 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG #define PIN_BUZZER 21 // IO3 is PWM2 // NEW: set this via protobuf instead! -// RAK4631 custom ringtone +// RAK4631 custom ringtone (but not for Pocket - it uses the default ringtone) +#ifndef WISMESH_POCKET #undef USERPREFS_RINGTONE_RTTTL #define USERPREFS_RINGTONE_RTTTL "Rak:d=32,o=5,b=200:b7,p,b7,4p,p" +#endif // Battery // The battery sense is hooked to pin A0 (5) @@ -282,7 +289,11 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG // VDD=3.3V AIN3=6/8*VDD=2.47V VBAT=1.66*AIN3=4.1V #define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_11_16 +#if defined(WISMESH_POCKET) +#define HAS_ETHERNET 0 +#else #define HAS_ETHERNET 1 +#endif #define RAK_4631 1 diff --git a/variants/nrf52840/rak_wismeshtag/platformio.ini b/variants/nrf52840/rak_wismeshtag/platformio.ini index acfbca99e..9b0d665b3 100644 --- a/variants/nrf52840/rak_wismeshtag/platformio.ini +++ b/variants/nrf52840/rak_wismeshtag/platformio.ini @@ -23,3 +23,11 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_LR2021=1 -DMESHTASTIC_EXCLUDE_WIFI=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak_wismeshtag> + +; RAK WisMesh Pod - same board as Tag (no user buttons on hardware; shares Tag pin map) +[env:rak_wismesh_pod] +extends = env:rak_wismeshtag +custom_meshtastic_display_name = RAK WisMesh Pod +build_flags = + ${env:rak_wismeshtag.build_flags} + -D WISMESH_POD \ No newline at end of file diff --git a/variants/nrf52840/rak_wismeshtag/variant.cpp b/variants/nrf52840/rak_wismeshtag/variant.cpp index a0394b2dd..ca0925f44 100644 --- a/variants/nrf52840/rak_wismeshtag/variant.cpp +++ b/variants/nrf52840/rak_wismeshtag/variant.cpp @@ -46,6 +46,14 @@ void initVariant() } #ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +static bool lowVddSystemOff = false; // Set when brownout forces System OFF (enables LPCOMP wake). + +// Strong override; must be noinline (see main-nrf52.cpp weak default). +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() +{ + return lowVddSystemOff; +} + void variant_nrf52LoopHook(void) { // If VDD stays unsafe for a while (brownout), force System OFF. @@ -72,6 +80,7 @@ void variant_nrf52LoopHook(void) low_vdd_timer_armed = true; } if ((uint32_t)(now - low_vdd_since_ms) >= (uint32_t)LOW_VDD_SYSTEMOFF_DELAY_MS) { + lowVddSystemOff = true; cpuDeepSleep(portMAX_DELAY); } } else { diff --git a/variants/nrf52840/rak_wismeshtag/variant.h b/variants/nrf52840/rak_wismeshtag/variant.h index 9ea215e42..c9188c485 100644 --- a/variants/nrf52840/rak_wismeshtag/variant.h +++ b/variants/nrf52840/rak_wismeshtag/variant.h @@ -55,10 +55,13 @@ extern "C" { /* * Buttons */ - +#ifndef WISMESH_POD #define PIN_BUTTON1 9 // Pin for button on E-ink button module or IO expansion #define BUTTON_NEED_PULLUP #define PIN_BUTTON2 12 +#else +#define HAS_BUTTON 0 +#endif /* * Analog pins diff --git a/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp b/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp index 2ad8b92b8..15a587e9a 100644 --- a/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp +++ b/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp @@ -68,4 +68,6 @@ void initVariant() pinMode(GPS_RTC_INT, OUTPUT); digitalWrite(GPS_RTC_INT, LOW); + + pinMode(PIN_BUZZER, INPUT_PULLDOWN); } diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index de8f2b74b..5ed5be1d7 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -16,4 +16,11 @@ build_flags = -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +lib_deps = + ${stm32_base.lib_deps} + # renovate: datasource=github-tags depName=STM32RTC packageName=stm32duino/STM32RTC + https://github.com/stm32duino/STM32RTC/archive/refs/tags/1.9.0.zip + # renovate: datasource=github-tags depName=STM32LowPower packageName=stm32duino/STM32LowPower + https://github.com/stm32duino/STM32LowPower/archive/refs/tags/1.5.0.zip + upload_port = stlink diff --git a/variants/stm32/rak3172/variant.h b/variants/stm32/rak3172/variant.h index 75e3e0c91..b7afefc3f 100644 --- a/variants/stm32/rak3172/variant.h +++ b/variants/stm32/rak3172/variant.h @@ -24,4 +24,7 @@ Do not expect a working Meshtastic device with this target. #define RAK3172 #define SERIAL_PRINT_PORT 1 +#define HAS_LSE 1 +#define STM32WL_LSE_DRIVE RCC_LSEDRIVE_LOW + #endif diff --git a/variants/stm32/russell/platformio.ini b/variants/stm32/russell/platformio.ini index 57f73f6d0..1900a9ae1 100644 --- a/variants/stm32/russell/platformio.ini +++ b/variants/stm32/russell/platformio.ini @@ -14,13 +14,11 @@ build_flags = -Ivariants/stm32/russell -DPRIVATE_HW -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 - -DMESHTASTIC_EXCLUDE_RANGETEST=1 -DMESHTASTIC_EXCLUDE_DETECTIONSENSOR=1 -DMESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION=1 -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 -DMESHTASTIC_EXCLUDE_NEIGHBORINFO=1 -DMESHTASTIC_EXCLUDE_TRACEROUTE=1 - -DMESHTASTIC_EXCLUDE_WAYPOINT=1 lib_deps = ${stm32_base.lib_deps} # renovate: datasource=custom.pio depName=Adafruit BME280 packageName=adafruit/library/Adafruit BME280 Library diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 5726fd369..4300e3b7a 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -26,6 +26,10 @@ build_flags = -DMESHTASTIC_EXCLUDE_WIFI=1 -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; The Ed25519 signing code does not fit in the 256KB flash. Packets are sent unsigned, like pre-XEdDSA firmware. + -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 + -DMESHTASTIC_EXCLUDE_RANGETEST=1 + -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + -DMESHTASTIC_EXCLUDE_POWER_TELEMETRY=1 -DSERIAL_RX_BUFFER_SIZE=256 ; For GPS - the default of 64 is too small. -DHAS_SCREEN=0 ; Always disable screen for STM32, it is not supported. ;-DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF ; Enable this if enabling debugg logging. It is REQUIRED for at least traceroute debug prints - without it the length returned by printf ends up uninitialized.