Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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!`.
Expand All @@ -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`).
75 changes: 58 additions & 17 deletions flasher/flasher.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import tempfile
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path

Expand All @@ -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"

Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions flasher/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions flasher/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading