diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0265a1..3911550 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,9 @@ jobs: - name: Run native unit tests run: pio test -e native + + - name: Install flasher test dependencies + run: pip install -r flasher/requirements-dev.txt + + - name: Run flasher unit tests + run: pytest flasher/test_flasher.py -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71ac016..bea6d1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,7 +81,7 @@ jobs: python-version: "3.12" - name: Install dependencies - run: pip install -r flasher/requirements.txt pyinstaller + run: pip install -r flasher/requirements.txt "pyinstaller>=6.0,<7" - name: Smoke test (imports + port scan, no GUI) run: python flasher/flasher.py --check diff --git a/CLAUDE.md b/CLAUDE.md index 9f350ce..c049cb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,18 +18,21 @@ compile anything. | Path | Purpose | |---|---| -| `src/protocol.{h,cpp}` | Wire format: heartbeat + warning packets, versioned (`kVersion`) | +| `src/protocol.{h,cpp}` | Wire format: heartbeat + warning + gossip packets, versioned (`kVersion`) | | `src/roster.{h,cpp}` | Peer tracking: dual-EMA RSSI trend, falling-back/dropped-off detection | -| `src/ui.{h,cpp}` | OLED rendering + single-button state machine (largest file, ~1200 lines) | +| `src/coop.{h,cpp}` | Cooperative drop-off confirmation: anomaly-only gossip mesh that corroborates a peer's local falling-back/dropped-off verdict across the group before it becomes an alert | +| `src/ui.{h,cpp}` | OLED rendering + single-button state machine (largest file, ~1300 lines) | | `src/config.{h,cpp}` | Pin map, tunable constants, persisted per-device settings (NVS), serial console | -| `src/power.{h,cpp}` | INA219 battery reading, low-battery latch | +| `src/power.{h,cpp}` | INA219 battery reading, low-battery latch, charging detection | +| `src/charging_decision.{h,cpp}` | Pure hysteresis/dwell-time state machine behind `Power::isCharging()` | | `src/radio.{h,cpp}` | SX1262 GFSK wrapper, EU868 duty-cycle budget | | `src/stats.{h,cpp}` | Per-tour counters (not persisted) for the stats screen | | `src/battery_curve.{h,cpp}` | LiPo voltage → percent curve, shared by local + peer battery display | +| `src/node_id.h` | Derives the on-wire node id from the ESP32's factory MAC | | `src/main.cpp` | Entry point wiring the modules together | | `flasher/flasher.py` | Standalone GUI flasher + device-settings tool (esptool-based) | | `docs/ui-mockups.md` + `docs/mockups/*.svg` | One rendered mockup per UI screen state; regenerate via `docs/mockups/generate.py` | -| `test/` | Native unit tests (protocol roundtrips, battery curve) — no hardware needed | +| `test/` | Native unit tests (protocol, coop, charging decision, battery curve, node id roundtrips) — no hardware needed | | `.github/workflows/` | CI (build + test on push) and release (tag-triggered firmware + flasher builds) | ## Build, test, flash diff --git a/README.md b/README.md index 1ad9c61..1e0821d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ No Meshtastic on board — its own lean broadcast protocol, tailored to a group | Component | Detail | |---|---| | MCU | Seeed Studio XIAO ESP32-S3 | -| Radio | Wio-SX1262 (LoRa), plugged onto the XIAO (B2B connector) | +| Radio | Wio-SX1262 (used in **GFSK** mode, not LoRa — see "Radio protocol" below), plugged onto the XIAO (B2B connector) | | Display | 0.96" OLED SSD1306, 128×64, I²C (two-tone panel: top 16 pixel rows yellow, rest blue) | | Battery monitor | INA219, I²C, high-side | | Battery | LiPo 803040, 1000 mAh | @@ -142,7 +142,9 @@ Releases are **fully automatic** — there's no manual tagging step in the norma A maintainer can still push a tag by hand (`git tag vX.Y.Z && git push origin vX.Y.Z`) to trigger a one-off release outside this flow. -**One-time setup for a new fork:** create a public GitHub repo and set the `GITHUB_REPO` constant in `flasher/flasher.py` from the placeholder to `user/repo` — before that, the app can only flash local files. +**One-time setup for a new fork:** +- Set the `GITHUB_REPO` constant in `flasher/flasher.py` to your fork's `user/repo`. Until your fork has published its first release, the flasher's "Download latest version" button reports "no releases found" — use "Local file ..." in the meantime. +- Add a repo secret named `RELEASE_PAT`: a [personal access token](https://github.com/settings/tokens) with `contents: write` (classic PAT: the `repo` scope) on your fork. `auto-tag.yml` needs it to push the release tag — GitHub's default `GITHUB_TOKEN` is deliberately prevented from triggering another workflow (the anti-recursion guard, see the comment at the top of `auto-tag.yml`), so a tag pushed with it would silently never kick off `release.yml`. Without `RELEASE_PAT`, pushes to `main` are never tagged or released at all. ## Provisioning (once per device) @@ -174,7 +176,7 @@ If you don't have `pio device monitor` handy: the **Vaura Flasher** has the "Dev The 5-character limit isn't a round number, it's calculated: the idle screen lists riders in up to **three columns**, and the status lives directly in the name there — `!NAME!` = signal fading, `(NAME)` = dropped off. A name decorated like this takes 5 + 2 = 7 characters, and three 7-character columns exactly fill the 21 characters that fit on one line at 128 px display width and 6 px/character (`u8g2_font_6x10_tf`). A longer name is **rejected** by the `name` command (not silently truncated), with an error message pointing at the limit. (A 6-character name stored under older firmware is automatically shortened to 5 on the first boot.) -> **Firmware update note:** The 5-character limit changed the radio packet format (protocol version 2). **v2 is radio-incompatible with v1** — both sides cleanly reject each other's packets at the version byte. All devices in the club need to be updated together. +> **Firmware update note:** The 5-character limit changed the radio packet format, bumping the wire-format version (see `Protocol::kVersion` in `src/protocol.h` for the current value and the full bump history — cooperative drop-off confirmation bumped it again since). **Every version bump is radio-incompatible with the previous one** — both sides cleanly reject each other's packets at the version byte. All devices in the club need to be updated together whenever this happens. ## UI mockups @@ -326,7 +328,7 @@ EU868, 868.3 MHz, **GFSK** (not LoRa) at 19.2 kb/s, ~10 kHz frequency deviation, With **at least 2 devices**: -1. `pio run -t upload` on both (all devices together — v2 is radio-incompatible with v1, see above), then set nicknames once each (console, or more conveniently: Flasher → "Device"). +1. `pio run -t upload` on both (all devices together — every wire-format version bump is radio-incompatible with the previous one, see above), then set nicknames once each (console, or more conveniently: Flasher → "Device"). 2. Power on both → the **channel prompt** appears with a countdown; long press (or wait 10 s) confirms channel 0. The event line then briefly shows the firmware version (`FW v0.1.x`), and after a few seconds the respective other rider appears in the list (with RSSI), along with `2/2` in the header (the counter includes your own device). 3. Short press → cycle through → long press on "CAR BEHIND" → the other device shows the warning + beeps 2× short. Short press there dismisses it again. 4. **Double click** on one device (even with the display asleep) → the other immediately shows `ATTENTION!`. @@ -345,3 +347,12 @@ With **at least 2 devices**: - Verify the 868 MHz antenna before the first transmit test. - Optional: AES encryption of packets (currently off, format in `src/protocol.h` prepared for a later extension). - Expansion stage 2: dedicated buttons per warning (case model change) + a vibration motor (the piezo beeper is already implemented), so alerts are perceivable without looking at the display; possibly GPS for real distance readings, an SOS function, fall detection. + +## License + +MIT — see [LICENSE](LICENSE). Built on top of several third-party open-source libraries, each under +its own license: [RadioLib](https://github.com/jgromes/RadioLib), [U8g2](https://github.com/olikraus/u8g2), +[OneButton](https://github.com/mathertel/OneButton), [Adafruit INA219](https://github.com/adafruit/Adafruit_INA219), +[esptool](https://github.com/espressif/esptool), and [certifi](https://github.com/certifi/python-certifi) — +none of them are redistributed in this repo itself, only pulled in at build time (`platformio.ini`, +`flasher/requirements.txt`). diff --git a/flasher/flasher.py b/flasher/flasher.py index b44ee5a..70330b8 100644 --- a/flasher/flasher.py +++ b/flasher/flasher.py @@ -19,6 +19,7 @@ import tempfile import threading import time +import urllib.error import urllib.request from pathlib import Path @@ -27,8 +28,10 @@ # --------------------------------------------------------------------------- # GitHub repo (public) whose releases the flasher downloads firmware from -- # format "user/repo". Update this if you fork the project under a different -# repo; while it points to a nonexistent/placeholder repo, only "Local -# file ..." works (the download button explains that then). +# repo. If the repo has no releases yet (e.g. right after forking, before +# the auto-tag/release pipeline has produced one), fetch_latest_release() +# surfaces a clear "no releases yet" message -- "Local file ..." always +# works regardless. # --------------------------------------------------------------------------- GITHUB_REPO = "bin101/vaura" @@ -168,17 +171,60 @@ def list_serial_ports(): return ports +def build_port_labels(ports): + """[(device, description, likely), ...] (see list_serial_ports()) -> + (combobox values, the label to preselect (or None), {label: device}). + + Pulled out of the GUI so the label<->device mapping is a plain, testable + function: the label is decorative (adds "(description)" and a "likely" + marker for display) but selected_port() must always be able to recover + the *real* port string from whichever label ends up selected.""" + values, preselect = [], None + port_by_label = {} + for device, desc, likely in ports: + label = f"{device} ({desc})" if desc else device + if likely: + label += " ← likely the Vaura device" + preselect = preselect or label + port_by_label[label] = device + values.append(label) + return values, preselect, port_by_label + + +def label_to_port(label, port_by_label): + """Recovers the actual port device string a refresh_ports()-built combobox + label stands for. Prefers the label->device map built alongside the same + labels (build_port_labels()); falls back to splitting on the description's + opening " (" for a label not in the map (e.g. one left over from before + the last refresh). The fallback alone is not enough on its own: a "likely" + label for a device with no description (e.g. "COM3 ← likely the Vaura + device") has no " (" to split on at all, so without the map lookup this + would return the whole label -- including the arrow suffix -- instead of + the port.""" + if label in port_by_label: + return port_by_label[label] + return label.split(" (")[0].strip() + + def fetch_latest_release(): """(tag, download_url) of the latest release, raises on errors.""" - if "YOUR-GITHUB-USER" in GITHUB_REPO: - raise RuntimeError( - "flasher.py has no GitHub repo configured yet (GITHUB_REPO).\n" - "Use 'Local file ...' or set the constant." - ) url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" req = urllib.request.Request(url, headers={"User-Agent": APP_TITLE}) - with urllib.request.urlopen(req, timeout=15, context=SSL_CONTEXT) as resp: - release = json.load(resp) + try: + with urllib.request.urlopen(req, timeout=15, context=SSL_CONTEXT) as resp: + release = json.load(resp) + except urllib.error.HTTPError as e: + if e.code == 404: + # GitHub returns 404 both for a nonexistent repo and for one with + # zero releases yet (a fresh fork before its first release) -- + # either way, "Local file ..." is the only option until that + # changes. + raise RuntimeError( + f"No releases found for '{GITHUB_REPO}' (HTTP 404). If you " + "forked this project, set GITHUB_REPO in flasher.py to your " + "fork once it has a release. Use 'Local file ...' for now." + ) from e + raise for asset in release.get("assets", []): if asset.get("name") == FIRMWARE_ASSET_NAME: return release.get("tag_name", "?"), asset["browser_download_url"] @@ -374,6 +420,7 @@ def __init__(self, root): self.firmware_label_text = None self.busy = False self.msg_queue = queue.Queue() + self._port_by_label = {} # populated by refresh_ports(), see selected_port() root.title(APP_TITLE) root.minsize(560, 640) @@ -524,13 +571,7 @@ def update_buttons(self): def refresh_ports(self): ports = list_serial_ports() - values, preselect = [], None - for device, desc, likely in ports: - label = f"{device} ({desc})" if desc else device - if likely: - label += " ← likely the Vaura device" - preselect = preselect or label - values.append(label) + values, preselect, self._port_by_label = build_port_labels(ports) self.cmb_port["values"] = values current = self.port_var.get() if preselect: @@ -540,7 +581,7 @@ def refresh_ports(self): self.update_buttons() def selected_port(self): - return self.port_var.get().split(" (")[0].strip() + return label_to_port(self.port_var.get(), self._port_by_label) # --- Actions ------------------------------------------------------------- def on_pick_file(self): diff --git a/flasher/requirements-dev.txt b/flasher/requirements-dev.txt new file mode 100644 index 0000000..debdebf --- /dev/null +++ b/flasher/requirements-dev.txt @@ -0,0 +1,4 @@ +# Extra dependencies for running flasher/test_flasher.py -- not needed to +# just run the flasher itself, see requirements.txt for that. +-r requirements.txt +pytest diff --git a/flasher/requirements.txt b/flasher/requirements.txt index 4186815..b761585 100644 --- a/flasher/requirements.txt +++ b/flasher/requirements.txt @@ -1,8 +1,11 @@ -# esptool 4.x: stabile CLI-/main()-API, bringt pyserial mit. -# tkinter kommt aus der Python-Standardbibliothek. +# esptool 4.x: stable CLI/main() API, brings pyserial along with it. +# tkinter comes from the Python standard library. esptool>=4.7,<5 # Explicit CA bundle for the GitHub download -- some Python installs (notably # python.org builds on macOS) don't have the system trust store wired up to # the ssl module, which makes urllib fail with CERTIFICATE_VERIFY_FAILED. -certifi +# Pinned to a floor version with a fix for a known-expired intermediate CA +# bundled in older releases (avoids a resurrected CERTIFICATE_VERIFY_FAILED +# from certifi itself going stale). +certifi>=2024.7.4 diff --git a/flasher/test_flasher.py b/flasher/test_flasher.py new file mode 100644 index 0000000..7a29819 --- /dev/null +++ b/flasher/test_flasher.py @@ -0,0 +1,172 @@ +"""Unit tests for flasher.py's pure helper functions -- no hardware, no GUI. + +Run with: pytest flasher/test_flasher.py (from the repo root), or +`cd flasher && pytest`. These cover exactly the functions where past bugs +lived (port-label round-tripping, the status key=value parser, the merged- +binary NVS split), since they're plain data-in/data-out logic that doesn't +need a device or a Tk window to exercise. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import flasher # noqa: E402 + + +# --------------------------------------------------------------------------- +# parse_status_lines() -- the `status` response parser, and the exact +# interface contract with the firmware's config.cpp (SETTINGS_KEYS). +# --------------------------------------------------------------------------- + +def test_parse_status_lines_typical_response(): + lines = [ + "name=ROB", + "id=1A2B", + "version=v0.4.0", + "channel=3", + "sensitivity=5", + "tone=6", + "display=30", + ] + assert flasher.parse_status_lines(lines) == { + "name": "ROB", + "id": "1A2B", + "version": "v0.4.0", + "channel": "3", + "sensitivity": "5", + "tone": "6", + "display": "30", + } + + +def test_parse_status_lines_ignores_unrelated_log_lines(): + lines = [ + "Radio: SX1262 ready (EU868, GFSK).", + "name=ROB", + "Node ID: 1A2B Nickname: ROB Channel: 0 FW v0.4.0 (console: 'help')", + "id=1A2B", + ] + assert flasher.parse_status_lines(lines) == {"name": "ROB", "id": "1A2B"} + + +def test_parse_status_lines_ignores_keys_outside_the_contract(): + # A stray "key=value"-shaped line whose key isn't part of SETTINGS_KEYS + # (e.g. a future firmware addition, or the `charge` command's output, + # which is deliberately excluded from `status`) must not leak in. + lines = ["name=ROB", "chargeCurrentMa=12"] + assert flasher.parse_status_lines(lines) == {"name": "ROB"} + + +def test_parse_status_lines_empty_input(): + assert flasher.parse_status_lines([]) == {} + + +def test_settings_keys_has_no_duplicates(): + assert len(flasher.SETTINGS_KEYS) == len(set(flasher.SETTINGS_KEYS)) + + +# --------------------------------------------------------------------------- +# build_port_labels() / label_to_port() -- the port-selection round trip. +# This is where the empty-description bug lived: a "likely" label for a +# device with no description has no " (" substring to split on. +# --------------------------------------------------------------------------- + +def test_build_port_labels_with_description(): + values, preselect, by_label = flasher.build_port_labels( + [("/dev/ttyACM0", "USB Serial", False)]) + assert values == ["/dev/ttyACM0 (USB Serial)"] + assert preselect is None + assert by_label == {"/dev/ttyACM0 (USB Serial)": "/dev/ttyACM0"} + + +def test_build_port_labels_likely_device_without_description(): + # The exact regression case: list_ports.comports() returned an empty + # description for the auto-detected Espressif device (observed on some + # platforms for the ESP32-S3 native USB-CDC/JTOG port). + values, preselect, by_label = flasher.build_port_labels( + [("COM3", "", True)]) + assert values == ["COM3 ← likely the Vaura device"] + assert preselect == "COM3 ← likely the Vaura device" + assert by_label == {"COM3 ← likely the Vaura device": "COM3"} + + +def test_build_port_labels_likely_device_with_description(): + values, preselect, by_label = flasher.build_port_labels( + [("/dev/ttyACM0", "USB JTAG/serial debug unit", True)]) + label = "/dev/ttyACM0 (USB JTAG/serial debug unit) ← likely the Vaura device" + assert values == [label] + assert preselect == label + assert by_label == {label: "/dev/ttyACM0"} + + +def test_build_port_labels_first_likely_wins_preselect(): + values, preselect, by_label = flasher.build_port_labels([ + ("COM3", "", True), + ("COM4", "", True), + ]) + assert preselect == "COM3 ← likely the Vaura device" + assert by_label["COM3 ← likely the Vaura device"] == "COM3" + assert by_label["COM4 ← likely the Vaura device"] == "COM4" + + +def test_label_to_port_round_trips_every_build_port_labels_shape(): + ports = [ + ("/dev/ttyACM0", "USB Serial", False), + ("COM3", "", True), + ("/dev/ttyACM1", "USB JTAG/serial debug unit", True), + ] + values, _preselect, by_label = flasher.build_port_labels(ports) + expected = {label: device for label, (device, _desc, _likely) in + zip(values, ports)} + for label in values: + assert flasher.label_to_port(label, by_label) == expected[label] + + +def test_label_to_port_falls_back_for_a_label_not_in_the_map(): + # A stale selection left over from before the last refresh_ports() call + # (map rebuilt, old label temporarily still in the combobox's textvariable) + # still recovers a sane port via the " (" split fallback. + assert flasher.label_to_port("/dev/ttyUSB0 (FTDI)", {}) == "/dev/ttyUSB0" + + +def test_label_to_port_empty_selection(): + assert flasher.label_to_port("", {}) == "" + + +# --------------------------------------------------------------------------- +# split_around_nvs() -- merged-binary splitting so flashing preserves the +# device's persisted settings (nickname, tone, etc. living in the NVS gap). +# --------------------------------------------------------------------------- + +def test_split_around_nvs_too_short_is_not_a_merged_binary(tmp_path): + firmware = tmp_path / "tiny.bin" + firmware.write_bytes(b"\x00" * (flasher.NVS_END - 1)) + assert flasher.split_around_nvs(str(firmware)) is None + + +def test_split_around_nvs_splits_around_the_nvs_gap(tmp_path): + before = b"\xAA" * flasher.NVS_START + nvs_gap = b"\xFF" * (flasher.NVS_END - flasher.NVS_START) + after = b"\xBB" * 4096 + firmware = tmp_path / "merged.bin" + firmware.write_bytes(before + nvs_gap + after) + + parts = flasher.split_around_nvs(str(firmware)) + assert parts is not None + assert [offset for offset, _path in parts] == [0x0, flasher.NVS_END] + + try: + (_off0, path0), (_off1, path1) = parts + assert Path(path0).read_bytes() == before + assert Path(path1).read_bytes() == after + finally: + for _offset, path in parts: + Path(path).unlink(missing_ok=True) + + +if __name__ == "__main__": + import pytest + + sys.exit(pytest.main([__file__, "-v"])) diff --git a/platformio.ini b/platformio.ini index c89bbb2..e0329dd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -20,7 +20,7 @@ lib_deps = jgromes/RadioLib@^6.6.0 olikraus/U8g2@^2.35.19 mathertel/OneButton@^2.6.1 - https://github.com/adafruit/Adafruit_INA219.git + adafruit/Adafruit INA219@^1.2.3 build_flags = -D CORE_DEBUG_LEVEL=2 diff --git a/src/config.cpp b/src/config.cpp index 578083f..aff58d5 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -165,12 +165,27 @@ void handleLine(const String &lineIn) { // drive -- the one thing worth tuning here is the frequency, since a // piezo is loudest right at its mechanical resonance. Play a fixed test // duration so repeated "beep " calls are directly comparable by ear. - unsigned int freq = arg.length() == 0 ? beepFrequencyHz() : static_cast(arg.toInt()); - if (freq == 0) { - Serial.println(F("Error: invalid frequency.")); - } else { + // Range-checked as a `long` before any cast to an unsigned type: unlike + // the persisted `tone` setting (which only ever comes from the fixed + // BEEP_FREQUENCY_MIN/MAX_HZ ruler), this is free-form test input, and + // toInt()'s raw result can be negative or huge (e.g. "beep -5" or + // "beep 999999") -- both would otherwise slip past a plain `== 0` check + // once cast to unsigned int. + constexpr long kMinTestHz = 100; + constexpr long kMaxTestHz = 10000; + if (arg.length() == 0) { + unsigned int freq = beepFrequencyHz(); Serial.printf("Beeping at %u Hz...\n", freq); tone(PIN_PIEZO, freq, 600); + } else { + long freqArg = arg.toInt(); + if (freqArg < kMinTestHz || freqArg > kMaxTestHz) { + Serial.printf("Error: expected a frequency between %ld and %ld Hz.\n", kMinTestHz, kMaxTestHz); + } else { + unsigned int freq = static_cast(freqArg); + Serial.printf("Beeping at %u Hz...\n", freq); + tone(PIN_PIEZO, freq, 600); + } } } else if (lower.equals("charge")) { // Not part of `status` -- this is a one-time hardware calibration aid @@ -179,8 +194,12 @@ void handleLine(const String &lineIn) { Serial.printf("chargeCurrentMa=%d isCharging=%s batteryMv=%u\n", Power::chargeCurrentMilliamps(), Power::isCharging() ? "yes" : "no", Power::batteryMillivolts()); + // INA219_CURRENT_CHARGE_SIGN is currently -1 (see config.h), so a + // correctly-wired board reads chargeCurrentMa POSITIVE while actually + // charging. If it reads negative instead, this board's IN+/IN- ended up + // the other way round -- flip the sign to +1, not -1 (it's already -1). Serial.println(F("If chargeCurrentMa reads negative while actually charging, flip")); - Serial.println(F("INA219_CURRENT_CHARGE_SIGN in config.h to -1.")); + Serial.println(F("INA219_CURRENT_CHARGE_SIGN in config.h to +1 (it is currently -1).")); } else { Serial.println(F("Unknown command. 'help' for an overview.")); } diff --git a/src/power.cpp b/src/power.cpp index 647673c..1eb3b42 100644 --- a/src/power.cpp +++ b/src/power.cpp @@ -34,7 +34,17 @@ void refreshIfDue() { return; } float busVoltage = ina219.getBusVoltage_V(); // load-side voltage ~= battery voltage - lastMillivolts = static_cast(busVoltage * 1000.0f); + // Sanity-clamp before the uint16_t cast: a glitched/negative I2C read (bad + // bus, brownout) would otherwise wrap silently into a nonsensical value + // (e.g. a negative reading wraps to ~65000 mV) that then propagates into + // the battery display, the low-battery latch, AND -- via the heartbeat -- + // every peer's view of this rider's battery. A single-cell LiPo on this + // board never legitimately exceeds ~4.3 V; 5.0 V is a generous ceiling. + // Out-of-range readings simply keep the last known-good value rather than + // being trusted. + if (busVoltage >= 0.0f && busVoltage <= 5.0f) { + lastMillivolts = static_cast(busVoltage * 1000.0f); + } // Adafruit_INA219::begin() calibrates for 32V/2A internally (see init()), // so getCurrent_mA() works out of the box -- no extra setCalibration_*() // needed for the small currents a USB charger delivers here. diff --git a/src/protocol.cpp b/src/protocol.cpp index d3716e1..ca61ef1 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -111,7 +111,22 @@ bool decode(const uint8_t *data, size_t len, DecodedPacket &result) { if (len < kHeaderLen + 1) { return false; } - result.warningType = static_cast(data[kHeaderLen]); + uint8_t rawType = data[kHeaderLen]; + // Reject an out-of-enum byte outright rather than letting it flow + // through as an unrecognized WarningType that only degrades late, at + // display time, to warningLabel()'s "?" fallback -- MsgType's own + // unknown-value case is rejected the same way just below. + switch (static_cast(rawType)) { + case WarningType::CarBehind: + case WarningType::HazardAhead: + case WarningType::Stopping: + case WarningType::Regroup: + case WarningType::Attention: + break; + default: + return false; + } + result.warningType = static_cast(rawType); return true; } case MsgType::Gossip: { diff --git a/src/radio.cpp b/src/radio.cpp index 81563fb..e59ea2a 100644 --- a/src/radio.cpp +++ b/src/radio.cpp @@ -111,7 +111,16 @@ void begin() { radio.setPacketReceivedAction(onPacketReceived); lastBudgetRefillMs = millis(); - dutyCycleBudgetUs = kDutyCycleCapacityUs; // start with a full tank + // Start empty, not full: crediting a full hour's allowance on every boot + // would let a device that brownout-reboots mid-ride (a real scenario, see + // config.h's BOOT_CHANNEL_SELECT_TIMEOUT_MS comment) exceed the EU868 1% + // duty-cycle limit across the reboot boundary by simply power-cycling. The + // budget refills on its own (refillBudget(), called from every poll()/ + // send()) at the same steady rate either way -- this only costs the first + // ~833 ms after boot before there's enough banked to send the first + // ~8.3 ms-airtime heartbeat, not perceptible against + // BOOT_CHANNEL_SELECT_TIMEOUT_MS's own 10 s window. + dutyCycleBudgetUs = 0.0; startListening(); Serial.println("Radio: SX1262 ready (EU868, GFSK)."); @@ -171,9 +180,15 @@ bool send(uint8_t *data, size_t len) { // callback firing mid-transition. interruptArmed = false; int state = radio.transmit(data, len); - dutyCycleBudgetUs -= timeOnAirUs; - if (state != RADIOLIB_ERR_NONE) { + if (state == RADIOLIB_ERR_NONE) { + dutyCycleBudgetUs -= timeOnAirUs; + } else { + // No antenna time was actually spent on a failed transmit -- charging the + // budget anyway would needlessly (if conservatively) burn the shared + // duty-cycle allowance on transient SPI/radio errors, and would also + // disagree with Stats::countWarningSent()'s "only successful sends count" + // policy (see main.cpp/ui.cpp callers). Serial.printf("Radio: transmit error, code %d\n", state); } diff --git a/src/roster.cpp b/src/roster.cpp index 14ce76f..6e7c741 100644 --- a/src/roster.cpp +++ b/src/roster.cpp @@ -90,7 +90,19 @@ Peer *findOrCreate(uint16_t nodeId) { } } if (free_slot == nullptr) { - return nullptr; // roster full -- silently ignore, MAX_PEERS is generous for club rides + // Roster full -- MAX_PEERS is generous for a club ride, and slots are + // never released (see the comment above), so this can only happen with + // an unusually large or long-lived group. Still worth one log line: this + // is a safety-relevant device, and a rider beyond the cap would otherwise + // go completely untracked/unalerted with no trace at all. Logged once + // per boot (not once per dropped heartbeat) so a sustained overflow + // doesn't spam the serial console. + static bool warnedFull = false; + if (!warnedFull) { + warnedFull = true; + Serial.println("Roster: full (MAX_PEERS reached) -- further new peers won't be tracked until the next boot."); + } + return nullptr; } // Reset every field, not just the flags: slots are never released today, // but if eviction is ever added, a reused slot must not inherit the previous diff --git a/src/ui.cpp b/src/ui.cpp index 2015222..9c7009e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -913,6 +913,14 @@ void setChargingMode(bool charging, uint8_t percent, uint16_t millivolts, int16_ // never honor its own 10 s auto-off while charging continued. state = State::Charging; stateEnteredMs = millis(); + // A warning repeat armed just before charging began must not fire once + // charging ends: main.cpp suspends the radio for the whole charging + // window, and the repeat would otherwise transmit stale/duplicate + // content well after the fact. tick()'s pending-repeat check already + // can't run while state == Charging (see tick()'s early return); this + // also drops the now-moot retry outright rather than letting it fire on + // the first post-charging tick. + pendingRepeatArmed = false; wakeDisplay(); } else if (!charging && state == State::Charging) { // Falling edge -- back to the rider's normal idle screen and display @@ -947,31 +955,67 @@ void begin() { render(); } +// One entry per State that auto-returns to Idle (or a state-specific action, +// see BootChannelSelect below) after a fixed duration with no interaction. +// Replaces what used to be a hand-written if-chain in tick(): CLAUDE.md +// warned that chain was a plain `if`-chain rather than a `switch`, so the +// compiler couldn't flag a State value that should time out but doesn't yet +// have an entry -- a table doesn't fix that (it's still just data), but it +// does mean a new timed state is one row here instead of a copy-pasted +// if-block easy to get subtly wrong (wrong variable, missed needsRender, +// ...), and there is exactly one place to scan instead of eleven scattered +// blocks. Idle/RangeTest/Charging are deliberately absent: Idle has nothing +// to time out of, RangeTest never times out by design (see its own comment +// in tick()), and Charging is handled by tick()'s own early return above +// this table (see that comment) -- none of the three could ever reach this +// loop with a real timeout to check anyway. +struct TimeoutEntry { + State state; + uint32_t timeoutMs; + void (*onTimeout)(); // nullptr = the common case, enterIdle() +}; +const TimeoutEntry kTimeouts[] = { + // Unattended reboot (battery brownout mid-ride): auto-confirm whatever + // channel is shown -- untouched, that is the persisted channel. The + // countdown resets on every click, so a touched-but-abandoned selection + // had 10 s on screen with "Start in Ns" before it wins. + {State::BootChannelSelect, BOOT_CHANNEL_SELECT_TIMEOUT_MS, confirmBootChannel}, + {State::Menu, UI_MENU_TIMEOUT_MS, nullptr}, + {State::IncomingWarning, UI_INCOMING_DISPLAY_MS, nullptr}, + // Abandons the edit -- nothing is saved until the last position is confirmed. + {State::Rename, UI_RENAME_TIMEOUT_MS, nullptr}, + {State::SettingsMenu, UI_MENU_TIMEOUT_MS, nullptr}, + // Abandons the change -- nothing is saved until confirmed with a long press. + {State::ToneMenu, UI_RENAME_TIMEOUT_MS, nullptr}, + {State::DisplayMenu, UI_RENAME_TIMEOUT_MS, nullptr}, + {State::SensitivityMenu, UI_RENAME_TIMEOUT_MS, nullptr}, + {State::ChannelMenu, UI_RENAME_TIMEOUT_MS, nullptr}, + {State::StatsScreen, UI_MENU_TIMEOUT_MS, nullptr}, + // Keeps the rider -- the next reminder cycle will ask again. The prompt's + // OTHER special behavior (refreshing the candidate every tick while it's + // open) is intentionally not a timeout and stays as its own check further + // down in tick(), not folded into this table. + {State::DismissPrompt, UI_INCOMING_DISPLAY_MS, nullptr}, +}; + void tick() { uint32_t now = millis(); - if (pendingRepeatArmed && static_cast(now - pendingRepeatDueMs) >= 0) { - bool sent = Radio::send(pendingRepeatBuf, pendingRepeatLen); - pendingRepeatArmed = false; - if (pendingRepeatFirstFailed && sent) { - // The retry got the warning out after all -- replace the failure toast. - // Counts as the (one) sent warning now; the failed first copy never did. - Stats::countWarningSent(); - char toast[24]; - snprintf(toast, sizeof(toast), "> %s", Protocol::warningLabel(pendingRepeatType)); - showToast(toast); - } - } - // Charging gets an early return, deliberately NOT an entry in the if-chain // below (see the CLAUDE.md note that tick()'s chain is a plain if-chain the // compiler won't flag for a missing case): its own fixed sleep window // (CHARGING_SCREEN_TIMEOUT_MS, independent of DeviceConfig::displayTimeoutMs() // -- see wakeDisplay()) is the only thing left to do. Everything else below - // (roster alerts, drop-off reminders, menu/edit timeouts) is moot -- the - // radio/roster themselves are suspended by main.cpp for the whole time - // state == Charging, and the state is only ever entered/left by - // setChargingMode(), never by a button gesture or a timeout. + // (roster alerts, drop-off reminders, menu/edit timeouts, and the pending + // warning-repeat retransmit) is moot -- the radio/roster themselves are + // suspended by main.cpp for the whole time state == Charging, and the state + // is only ever entered/left by setChargingMode(), never by a button gesture + // or a timeout. The pending-repeat check in particular MUST stay below this + // return: it calls Radio::send(), and main.cpp puts the radio to sleep + // before ever calling Ui::tick() while charging -- see + // setChargingMode()'s rising-edge handling, which also clears + // pendingRepeatArmed so a repeat armed right before charging began doesn't + // fire (on a still-sleeping radio) the instant charging ends either. if (state == State::Charging) { if (displayIsOn && static_cast(now - displayWakeUntilMs) >= 0) { sleepDisplay(); @@ -982,56 +1026,33 @@ void tick() { return; } + if (pendingRepeatArmed && static_cast(now - pendingRepeatDueMs) >= 0) { + bool sent = Radio::send(pendingRepeatBuf, pendingRepeatLen); + pendingRepeatArmed = false; + if (pendingRepeatFirstFailed && sent) { + // The retry got the warning out after all -- replace the failure toast. + // Counts as the (one) sent warning now; the failed first copy never did. + Stats::countWarningSent(); + char toast[24]; + snprintf(toast, sizeof(toast), "> %s", Protocol::warningLabel(pendingRepeatType)); + showToast(toast); + } + } + bool needsRender = false; - if (state == State::BootChannelSelect && now - stateEnteredMs > BOOT_CHANNEL_SELECT_TIMEOUT_MS) { - // Unattended reboot (battery brownout mid-ride): auto-confirm whatever is - // shown -- untouched, that is the persisted channel. The countdown resets - // with every click, so a touched-but-abandoned selection had 10 s on - // screen with "Start in Ns" before it wins. - confirmBootChannel(); - needsRender = true; - } - if (state == State::Menu && now - stateEnteredMs > UI_MENU_TIMEOUT_MS) { - enterIdle(); - needsRender = true; - } - if (state == State::IncomingWarning && now - stateEnteredMs > UI_INCOMING_DISPLAY_MS) { - enterIdle(); - needsRender = true; - } - if (state == State::Rename && now - stateEnteredMs > UI_RENAME_TIMEOUT_MS) { - enterIdle(); // abandons the edit -- nothing is saved until the last position is confirmed - needsRender = true; - } - if (state == State::SettingsMenu && now - stateEnteredMs > UI_MENU_TIMEOUT_MS) { - enterIdle(); - needsRender = true; - } - if (state == State::ToneMenu && now - stateEnteredMs > UI_RENAME_TIMEOUT_MS) { - enterIdle(); // abandons the change -- nothing is saved until confirmed with a long press - needsRender = true; - } - if (state == State::DisplayMenu && now - stateEnteredMs > UI_RENAME_TIMEOUT_MS) { - enterIdle(); // abandons the change -- nothing is saved until confirmed with a long press - needsRender = true; - } - if (state == State::SensitivityMenu && now - stateEnteredMs > UI_RENAME_TIMEOUT_MS) { - enterIdle(); // abandons the change -- nothing is saved until confirmed with a long press - needsRender = true; - } - if (state == State::ChannelMenu && now - stateEnteredMs > UI_RENAME_TIMEOUT_MS) { - enterIdle(); // abandons the change -- nothing is saved until confirmed with a long press - needsRender = true; - } - if (state == State::StatsScreen && now - stateEnteredMs > UI_MENU_TIMEOUT_MS) { - enterIdle(); - needsRender = true; - } - if (state == State::DismissPrompt && now - stateEnteredMs > UI_INCOMING_DISPLAY_MS) { - enterIdle(); // keeps the rider -- the next reminder cycle will ask again - needsRender = true; + for (const TimeoutEntry &entry : kTimeouts) { + if (state == entry.state && now - stateEnteredMs > entry.timeoutMs) { + if (entry.onTimeout != nullptr) { + entry.onTimeout(); + } else { + enterIdle(); + } + needsRender = true; + break; // state (and stateEnteredMs) just changed -- no other entry can also match now + } } + if (state == State::DismissPrompt) { // Keep the prompt honest while it is open: refresh the candidate every // tick so the "weg seit" age stays live, a rider who came back mid-prompt diff --git a/test/test_protocol/test_main.cpp b/test/test_protocol/test_main.cpp index 92b822b..62900b3 100644 --- a/test/test_protocol/test_main.cpp +++ b/test/test_protocol/test_main.cpp @@ -125,6 +125,24 @@ void test_reject_unknown_msg_type() { TEST_ASSERT_FALSE(Protocol::decode(buf, len, pkt)); } +void test_reject_unknown_warning_type() { + // A well-formed Warning packet whose type byte is out of WarningType's + // range (0 and 6+ are unassigned) must be rejected at decode, not let + // through to degrade later at display time (warningLabel()'s "?"). + uint8_t buf[Protocol::kMaxPacketLen]; + size_t len = Protocol::encodeWarning(buf, 1, 0, Protocol::WarningType::CarBehind); + Protocol::DecodedPacket pkt; + + buf[Protocol::kHeaderLen] = 0; // below WarningType::CarBehind (1) + TEST_ASSERT_FALSE(Protocol::decode(buf, len, pkt)); + + buf[Protocol::kHeaderLen] = static_cast(Protocol::WarningType::Attention) + 1; // above the last type + TEST_ASSERT_FALSE(Protocol::decode(buf, len, pkt)); + + buf[Protocol::kHeaderLen] = 0xFF; + TEST_ASSERT_FALSE(Protocol::decode(buf, len, pkt)); +} + void test_gossip_roundtrip_empty() { uint8_t buf[Protocol::kMaxPacketLen]; size_t len = Protocol::encodeGossip(buf, 0xABCD, 3, nullptr, 0); @@ -274,6 +292,7 @@ int main(int, char **) { RUN_TEST(test_reject_v1_heartbeat); RUN_TEST(test_reject_short_buffer); RUN_TEST(test_reject_unknown_msg_type); + RUN_TEST(test_reject_unknown_warning_type); RUN_TEST(test_gossip_roundtrip_empty); RUN_TEST(test_gossip_roundtrip_typical); RUN_TEST(test_gossip_golden_bytes_pins_wire_layout);