mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 11:28:11 +10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bdb5830b | ||
|
|
f9f450977b | ||
|
|
f74d3e4816 | ||
|
|
55954c20b7 | ||
|
|
55a580e19e | ||
|
|
6b15571e14 | ||
|
|
8fd0a7f283 | ||
|
|
e589de2d6e | ||
|
|
a277108c84 | ||
|
|
9e26cc3795 | ||
|
|
84bb909437 | ||
|
|
2beebea453 | ||
|
|
d0cd8ec366 | ||
|
|
d31d0f85fe | ||
|
|
2c8dec2fbd | ||
|
|
c48b5ab556 | ||
|
|
79e7ed30f1 | ||
|
|
d5af07e458 | ||
|
|
7d957f8c7b | ||
|
|
5cae9e0183 | ||
|
|
edf660ccb3 | ||
|
|
e7b66281fa | ||
|
|
15b474172a |
@@ -24,7 +24,12 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
||||
- `mcp__meshtastic__get_config(section="lora", port=<p>)` — region, preset, channel_num, tx_power, hop_limit.
|
||||
- Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=<p>, env=<inferred-env>)`, wait 3s, `serial_read(session_id=<s>, max_lines=100)`, `serial_close(session_id=<s>)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*<ROLE>` is set.
|
||||
|
||||
4. **Render per-device report** as:
|
||||
4. **Hub health** (call once, not per-device): `mcp__meshtastic__uhubctl_list()` — enumerates every USB hub the host can see. Note which hubs advertise `ppps=true` and which hub hosts each Meshtastic device (cross-reference by VID). Flag it in the report if:
|
||||
- No hub advertises PPPS → `tests/recovery/` can't run on this setup; hard-recovery via `uhubctl_cycle` isn't available.
|
||||
- A Meshtastic device is on a non-PPPS hub → note it; operator may want to move the device to a PPPS hub to unlock auto-recovery.
|
||||
- `uhubctl_list` raises `ConfigError: uhubctl not found` → just say `uhubctl not installed` in the report; don't treat as a fault.
|
||||
|
||||
5. **Render per-device report** as:
|
||||
|
||||
```text
|
||||
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
|
||||
@@ -33,20 +38,22 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
||||
tx_power : 30 dBm, hop_limit=3
|
||||
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
|
||||
primary ch : McpTest
|
||||
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
|
||||
firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts
|
||||
```
|
||||
|
||||
Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub), flag it inline with a short `⚠︎ <one-line reason>`.
|
||||
Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub, device on non-PPPS hub), flag it inline with a short `⚠︎ <one-line reason>`.
|
||||
|
||||
5. **Cross-device correlation** (only when >1 device is inspected):
|
||||
6. **Cross-device correlation** (only when >1 device is inspected):
|
||||
- Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it.
|
||||
- Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh)
|
||||
- Do the primary channel NAMES match? Mismatch = different PSK = no decode.
|
||||
|
||||
6. **Suggest next actions only for specific, recognisable failure modes**:
|
||||
7. **Suggest next actions only for specific, recognisable failure modes**:
|
||||
- Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path."
|
||||
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
|
||||
- Device unreachable → point at touch_1200bps + the CP2102-wedged-driver note in run-tests.sh.
|
||||
- Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
|
||||
- CP2102-wedged-driver on macOS → see the note in `run-tests.sh`.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
|
||||
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
|
||||
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
|
||||
- **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup.
|
||||
- **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer.
|
||||
- **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. For a device that's wedged past `touch_1200bps`, the next escalation is `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed).
|
||||
- **Device went dark mid-run** → fails from some attempt onward, never recovers, firmware log stops arriving. Almost always hardware: a Guru crash + frozen CDC. Hard-power-cycle via `uhubctl_cycle(role=..., confirm=True)` before the next iteration; if that also fails, escalate to replug.
|
||||
- **Genuinely unknown** → say so; don't invent a root cause.
|
||||
|
||||
7. **Report back** with:
|
||||
|
||||
@@ -19,16 +19,21 @@ Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn
|
||||
|
||||
2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped.
|
||||
|
||||
3. **On pass**: one-line summary of the form `N passed, M skipped in <duration>`. Don't enumerate the 52 test names — the user can read those. Do mention if any test was SKIPPED for a NON-placeholder reason (e.g. "role not present on hub" is worth flagging).
|
||||
3. **On pass**: one-line summary of the form `N passed, M skipped in <duration>`. Don't enumerate the test names — the user can read those. Do mention any SKIPPED tests and name the cause:
|
||||
- `"role not present on hub"` → device unplugged; operator knows to reconnect.
|
||||
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped because the macro isn't in firmware yet; suggest `--force-bake`.
|
||||
- `"uhubctl not installed"` → tests/recovery + peer-offline skipped; suggest `brew install uhubctl` / `apt install uhubctl`.
|
||||
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the hub doesn't support per-port power; the tier will never run on that setup.
|
||||
- `"opencv-python-headless is not installed"` → tests/ui auto-deselected by run-tests.sh; suggest `pip install -e 'mcp-server/.[ui]'`.
|
||||
|
||||
4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`).
|
||||
4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). For UI-tier failures also glance at `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` — it records each step's frame + OCR.
|
||||
|
||||
5. **Classify the failure** as one of:
|
||||
- **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro <test_node_id>` to confirm.
|
||||
- **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery (replug USB, `touch_1200bps`, check `git status userPrefs.jsonc`).
|
||||
- **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery in escalation order: (a) replug USB, (b) `touch_1200bps(port=...)` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role="nrf52", confirm=True)` when a device is fully wedged past DFU (needs `uhubctl` installed — `baked_single`'s auto-recovery hook does this once automatically). Also check `git status userPrefs.jsonc`.
|
||||
- **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible.
|
||||
|
||||
6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, or USB replug, \_describe what to do* — don't execute. The operator decides.
|
||||
6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or USB replug, \_describe what to do* — don't execute. The operator decides.
|
||||
|
||||
## Arguments handling
|
||||
|
||||
|
||||
@@ -474,7 +474,7 @@ The repo registers the server via `.mcp.json` at the repo root — Claude Code p
|
||||
|
||||
**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 (~32 tools)
|
||||
### MCP tool surface (43 tools)
|
||||
|
||||
Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here.
|
||||
|
||||
@@ -482,11 +482,13 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v
|
||||
- **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** (all 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`
|
||||
- **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`
|
||||
- **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_<ROLE>` + `_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
|
||||
- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` — grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured.
|
||||
|
||||
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset` and `erase_and_flash`.
|
||||
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`.
|
||||
|
||||
### Hardware test suite (`mcp-server/run-tests.sh`)
|
||||
|
||||
@@ -494,14 +496,16 @@ The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf5
|
||||
|
||||
Suite tiers (collected + run in this order via `pytest_collection_modifyitems`):
|
||||
|
||||
1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile). No hardware.
|
||||
1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile, uhubctl parser). No hardware.
|
||||
2. `tests/test_00_bake.py` — flashes each detected device with current `userPrefs.jsonc` merged with the session's test profile. Has its own skip-if-already-baked check comparing region + primary channel to the session profile; skips cheaply on warm devices.
|
||||
3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`.
|
||||
3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. Includes `test_peer_offline_recovery` which uses uhubctl to physically power off one peer mid-conversation (requires uhubctl; skips without).
|
||||
4. `tests/telemetry/` — `DEVICE_METRICS_APP` broadcast timing.
|
||||
5. `tests/monitor/` — boot-log panic check.
|
||||
6. `tests/fleet/` — PSK seed session isolation.
|
||||
7. `tests/admin/` — channel URL roundtrip, owner persistence across reboot.
|
||||
8. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset.
|
||||
6. `tests/recovery/` — `uhubctl` power-cycle round-trip + NVS persistence across hard reset. Requires `uhubctl` installed and a PPPS-capable hub; entire tier auto-skips otherwise.
|
||||
7. `tests/ui/` — input-broker-driven screen navigation with camera + OCR evidence.
|
||||
8. `tests/fleet/` — PSK seed session isolation.
|
||||
9. `tests/admin/` — channel URL roundtrip, owner persistence across reboot.
|
||||
10. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset.
|
||||
|
||||
Invocation patterns:
|
||||
|
||||
@@ -586,15 +590,19 @@ If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` fl
|
||||
|
||||
### Recovery playbooks
|
||||
|
||||
| Symptom | First check | Fix |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. |
|
||||
| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. |
|
||||
| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. |
|
||||
| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. |
|
||||
| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. |
|
||||
| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. |
|
||||
| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. |
|
||||
| Symptom | First check | Fix |
|
||||
| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. |
|
||||
| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. |
|
||||
| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. |
|
||||
| Device fully wedged (Guru Meditation, frozen CDC, no DFU) | `list_devices` shows the VID but every admin call times out | `uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles the port via USB hub PPPS. `baked_single`'s auto-recovery hook does this once automatically if uhubctl is installed. Falls back to physical replug if no PPPS hub. |
|
||||
| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. |
|
||||
| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. |
|
||||
| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. |
|
||||
| Entire `tests/recovery/` tier skipped | `command -v uhubctl` | Expected if `uhubctl` isn't on PATH. Install via `brew install uhubctl` (macOS) or `apt install uhubctl` (Debian/Ubuntu). Also skips if no hub advertises PPPS. |
|
||||
| Entire `tests/ui/` tier skipped ("firmware not baked with USERPREFS_UI_TEST_LOG") | reportlog.jsonl for the skip reason | Re-run with `--force-bake` so the UI-log macro gets compiled into the fresh firmware. First run after the Round-3 landing always re-bakes. |
|
||||
| `tests/ui/` runs but captures are all 1×1 black PNGs | `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3` | Env var not set → `NullBackend`. Point a USB webcam at the heltec-v3 OLED and set the device index; `.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"` discovers it. |
|
||||
| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. |
|
||||
|
||||
### Never do these without asking
|
||||
|
||||
|
||||
@@ -26,7 +26,12 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
|
||||
- `get_config(section="lora", port=<p>)` → region, preset, channel_num, tx_power, hop_limit
|
||||
- If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=<p>, env=<inferred>)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_<ROLE>` env var overrides it.
|
||||
|
||||
4. **Render per-device report** as a compact block:
|
||||
4. **Hub health** (call once, not per-device): `uhubctl_list()` — enumerates every USB hub the host sees. Cross-reference each Meshtastic device's VID to find which hub + port it's on. Flag in the report if:
|
||||
- No hub advertises `ppps=true` → `tests/recovery/` can't run; hard-recovery via `uhubctl_cycle` isn't available.
|
||||
- A Meshtastic device is on a non-PPPS hub → note it; moving to a PPPS hub unlocks auto-recovery.
|
||||
- `uhubctl_list` raises `ConfigError: uhubctl not found` → report as "uhubctl not installed"; don't treat as a device fault.
|
||||
|
||||
5. **Render per-device report** as a compact block:
|
||||
|
||||
```text
|
||||
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
|
||||
@@ -35,20 +40,22 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
|
||||
tx_power : 30 dBm, hop_limit=3
|
||||
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
|
||||
primary ch : McpTest
|
||||
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
|
||||
firmware : no panics in last 3s
|
||||
```
|
||||
|
||||
Flag abnormalities inline with `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, etc.
|
||||
Flag abnormalities inline with `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc.
|
||||
|
||||
5. **Cross-device correlation** (when >1 device selected):
|
||||
6. **Cross-device correlation** (when >1 device selected):
|
||||
- Do both see each other in `nodesByNum`?
|
||||
- Do `region`, `channel_num`, `modem_preset` match across devices?
|
||||
- Do the primary channel names match? (Different name → different PSK → no decode.)
|
||||
|
||||
6. **Suggest next steps only for recognizable failure modes**, never speculatively:
|
||||
7. **Suggest next steps only for recognizable failure modes**, never speculatively:
|
||||
- Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` — the test's retry+nodeinfo-ping heals this."
|
||||
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
|
||||
- Device unreachable → refer operator to the touch_1200bps + CP2102-wedged-driver notes in `run-tests.sh`.
|
||||
- Device unreachable, DFU reachable → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds and the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
|
||||
- CP2102-wedged-driver on macOS → see `run-tests.sh` notes.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one
|
||||
- **LoRa airtime collision** — pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body.
|
||||
- **PKI key staleness** — first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix.
|
||||
- **NodeInfo cooldown** — `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup.
|
||||
- **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc.
|
||||
- **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. For a device wedged past `touch_1200bps`, recommend `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed).
|
||||
- **Device went dark mid-run** — fails from some iteration onward and never recovers; firmware log stops arriving. Almost always a Guru crash with frozen CDC. Recommend `uhubctl_cycle` before the next iteration; escalate to replug if that also fails.
|
||||
- **Unknown** — say so. Don't invent a root cause.
|
||||
|
||||
7. **Report back** with:
|
||||
|
||||
@@ -21,19 +21,25 @@ Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md
|
||||
|
||||
2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only — call that out explicitly so the operator knows hardware tiers were skipped.
|
||||
|
||||
3. **On pass**: one-line summary like `N passed, M skipped in <duration>`. Don't enumerate test names. DO mention any non-placeholder SKIPs (things like "role not present on hub") because they indicate missing hardware or setup issues.
|
||||
3. **On pass**: one-line summary like `N passed, M skipped in <duration>`. Don't enumerate test names. DO mention any non-placeholder SKIPs and name the cause:
|
||||
- `"role not present on hub"` → device unplugged; operator should reconnect.
|
||||
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped; the UI-log compile macro isn't in the baked firmware. Suggest `--force-bake`.
|
||||
- `"uhubctl not installed"` → tests/recovery + `test_peer_offline_recovery` skipped. Suggest `brew install uhubctl` / `apt install uhubctl`.
|
||||
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the attached hub doesn't support per-port power switching; won't run on that setup.
|
||||
- `"opencv-python-headless is not installed"` → tests/ui auto-deselected by `run-tests.sh`. Suggest `pip install -e 'mcp-server/.[ui]'`.
|
||||
|
||||
4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise:
|
||||
- test name
|
||||
- one-line assertion message
|
||||
- the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`)
|
||||
- for UI-tier failures also check `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` (per-step frame + OCR)
|
||||
|
||||
5. **Classify each failure** as one of:
|
||||
- **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro <test-id>` to confirm.
|
||||
- **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest specific recovery (USB replug, `touch_1200bps`, `git status userPrefs.jsonc`).
|
||||
- **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest recovery in escalation order: (a) replug USB, (b) `touch_1200bps` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role=..., confirm=True)` for a device wedged past DFU (needs `uhubctl` installed; `baked_single` does this once automatically when available). Also check `git status userPrefs.jsonc`.
|
||||
- **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible.
|
||||
|
||||
6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval.
|
||||
6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval.
|
||||
|
||||
## Arguments convention
|
||||
|
||||
|
||||
@@ -4,14 +4,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# trunk-ignore(checkov/CKV_GHA_7)
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
arch:
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
@@ -20,18 +15,32 @@ on:
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
description: Choose an arch to limit the search, or 'all' to search all architectures.
|
||||
default: all
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
# find-target:
|
||||
# type: boolean
|
||||
# default: true
|
||||
# description: 'Find the available targets'
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
find-targets:
|
||||
if: ${{ inputs.target == '' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
- esp32c6
|
||||
- nrf52840
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -42,37 +51,14 @@ jobs:
|
||||
- run: pip install -U platformio
|
||||
- name: Generate matrix
|
||||
id: jsonStep
|
||||
env:
|
||||
BUILDTARGET: ${{ inputs.target }}
|
||||
MATRIXARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
|
||||
if [ "$BUILDTARGET" = "" ]; then
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "We build this one:" >> $GITHUB_STEP_SUMMARY
|
||||
ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform')
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "$ARCH" == "" ]]; then
|
||||
echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY
|
||||
echo "arch=$ARCH" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
outputs:
|
||||
arch: ${{ steps.jsonStep.outputs.arch }}
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Targets:" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
version:
|
||||
if: ${{ inputs.target != '' }}
|
||||
@@ -92,12 +78,12 @@ jobs:
|
||||
|
||||
build:
|
||||
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
||||
needs: [version, find-targets]
|
||||
needs: [version]
|
||||
uses: ./.github/workflows/build_firmware.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.long }}
|
||||
pio_env: ${{ inputs.target }}
|
||||
platform: ${{ needs.find-targets.outputs.arch }}
|
||||
platform: ${{ inputs.arch }}
|
||||
|
||||
gather-artifacts:
|
||||
permissions:
|
||||
|
||||
@@ -89,25 +89,42 @@ Sequence these; don't parallelize on the same port.
|
||||
|
||||
## Where to look
|
||||
|
||||
| Path | What's there |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) |
|
||||
| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI |
|
||||
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
|
||||
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
|
||||
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
|
||||
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
|
||||
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
|
||||
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `fleet/`, `admin/`, `provisioning/` |
|
||||
| `.claude/commands/` | Claude Code slash command bodies |
|
||||
| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) |
|
||||
| `.github/copilot-instructions.md` | **Primary agent instructions — read this** |
|
||||
| `.github/workflows/` | CI pipelines |
|
||||
| `.mcp.json` | MCP server registration for Claude Code |
|
||||
| Path | What's there |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) |
|
||||
| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI |
|
||||
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
|
||||
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
|
||||
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
|
||||
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
|
||||
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
|
||||
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` |
|
||||
| `.claude/commands/` | Claude Code slash command bodies |
|
||||
| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) |
|
||||
| `.github/copilot-instructions.md` | **Primary agent instructions — read this** |
|
||||
| `.github/workflows/` | CI pipelines |
|
||||
| `.mcp.json` | MCP server registration for Claude Code |
|
||||
|
||||
## Recovery one-liners
|
||||
|
||||
- **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`.
|
||||
- **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs.
|
||||
- **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access.
|
||||
- **Port busy?** `lsof <port>` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it.
|
||||
- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned.
|
||||
|
||||
## Environment variables (test harness)
|
||||
|
||||
| Var | Purpose |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MESHTASTIC_MCP_ENV_<ROLE>` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. |
|
||||
| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp-<user>-<host>`. |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. |
|
||||
| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. |
|
||||
| `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. |
|
||||
| `MESHTASTIC_UHUBCTL_PORT_<ROLE>` | Pin a role to a specific hub port number. Required alongside `LOCATION_<ROLE>`. |
|
||||
| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). |
|
||||
| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. |
|
||||
| `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). |
|
||||
| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). |
|
||||
| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. |
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, HIGH, LOW]
|
||||
MODE_TX: [HIGH, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
MODE_TX_HF: [LOW, LOW, LOW]
|
||||
MODE_GNSS: [LOW, LOW, HIGH]
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins:
|
||||
- DIO5
|
||||
- DIO6
|
||||
MODE_STBY:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_RX:
|
||||
- HIGH
|
||||
- LOW
|
||||
MODE_TX:
|
||||
- HIGH
|
||||
- HIGH
|
||||
MODE_TX_HP:
|
||||
- LOW
|
||||
- HIGH
|
||||
MODE_TX_HF:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_GNSS:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_WIFI:
|
||||
- LOW
|
||||
- LOW
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, LOW, LOW]
|
||||
MODE_TX: [LOW, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
# MODE_TX_HF: []
|
||||
# MODE_GNSS: []
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,31 +0,0 @@
|
||||
# For use with Armbian luckfox-pico-max
|
||||
# Waveshare LoRa HAT for Raspberry Pi Pico
|
||||
# https://www.waveshare.com/wiki/Pico-LoRa-SX1262
|
||||
|
||||
Meta:
|
||||
name: luckfox-pico-max-ws-raspberry-pi-pico-hat
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-pico-max # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
spidev: spidev0.0
|
||||
Busy: # GPIO1_C7 / GP2
|
||||
pin: 55
|
||||
gpiochip: 1
|
||||
line: 23
|
||||
CS: # GPIO1_C6 / GP3
|
||||
pin: 54
|
||||
gpiochip: 1
|
||||
line: 22
|
||||
Reset: # GPIO1_D1 / GP15
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
IRQ: # GPIO2_A2 / GP20
|
||||
pin: 66
|
||||
gpiochip: 2
|
||||
line: 2
|
||||
@@ -24,3 +24,6 @@ tests/.tui-runs
|
||||
tests/.history/
|
||||
# Reproducer bundles (TUI `x` export on failed tests).
|
||||
tests/reproducers/
|
||||
# UI-tier camera captures + per-test transcripts. Regenerated every run;
|
||||
# left on disk for human review between runs.
|
||||
tests/ui_captures/
|
||||
|
||||
+86
-2
@@ -61,7 +61,7 @@ Replace `<firmware-repo>` with the absolute path, e.g. `/Users/you/GitHub/firmwa
|
||||
|
||||
Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
|
||||
|
||||
## Tools (38)
|
||||
## Tools (43)
|
||||
|
||||
### Discovery & metadata
|
||||
|
||||
@@ -130,6 +130,34 @@ _The tool tables below document 38 currently registered MCP server tools._
|
||||
| `picotool_load` | Load a UF2 |
|
||||
| `picotool_raw` | Pass-through |
|
||||
|
||||
### USB power control (uhubctl)
|
||||
|
||||
| Tool | What it does |
|
||||
| --------------- | ----------------------------------------------------------- |
|
||||
| `uhubctl_list` | Enumerate USB hubs + attached-device VID/PID (read-only) |
|
||||
| `uhubctl_power` | Drive a hub port `on` or `off`; `off` requires confirm=True |
|
||||
| `uhubctl_cycle` | Off → wait `delay_s` → on; confirm=True required |
|
||||
|
||||
Target a port by explicit `(location, port)` (raw uhubctl syntax like
|
||||
`location="1-1.3", port=2`) or by `role` (`"nrf52"`, `"esp32s3"`). Role
|
||||
lookup checks `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
|
||||
`MESHTASTIC_UHUBCTL_PORT_<ROLE>` env vars first, then auto-detects via VID
|
||||
against `uhubctl`'s output.
|
||||
|
||||
Requires [`uhubctl`](https://github.com/mvp/uhubctl) on PATH:
|
||||
|
||||
```bash
|
||||
brew install uhubctl # macOS
|
||||
apt install uhubctl # Debian/Ubuntu
|
||||
```
|
||||
|
||||
Modern macOS + PPPS-capable hubs generally work without root. On Linux
|
||||
without udev rules, or on old macOS with driver quirks, you may need
|
||||
`sudo`. If uhubctl returns a permission error the MCP tool raises a
|
||||
clear `UhubctlError` pointing at the
|
||||
[udev-rules / sudo fallback](https://github.com/mvp/uhubctl#linux-usb-permissions)
|
||||
rather than auto-`sudo`'ing mid-run.
|
||||
|
||||
## Safety
|
||||
|
||||
- **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude.
|
||||
@@ -182,10 +210,22 @@ in the pre-flight header.
|
||||
- **`unit`** — pure Python, no hardware. boards / PIO wrapper /
|
||||
userPrefs-parse / testing-profile fixtures.
|
||||
- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK,
|
||||
traceroute, bidirectional. Parametrized over both directions.
|
||||
traceroute, bidirectional. Parametrized over both directions. Includes
|
||||
`test_peer_offline_recovery` which uses uhubctl to power-cycle one peer
|
||||
mid-conversation and verifies the mesh recovers (skips without uhubctl).
|
||||
- **`telemetry`** — periodic telemetry broadcast + on-demand request/reply
|
||||
(`TELEMETRY_APP` with `wantResponse=True`).
|
||||
- **`monitor`** — boot log has no panic markers within 60 s of reboot.
|
||||
- **`recovery`** — `uhubctl` power-cycle round-trip: verifies the hub port
|
||||
can be toggled off/on, the device re-enumerates with the same
|
||||
`my_node_num`, and NVS-resident config (region, channel, modem preset)
|
||||
survives a hard reset. Requires `uhubctl` on PATH; skips cleanly otherwise.
|
||||
- **`ui`** — input-broker-driven screen navigation (`AdminMessage.send_input_event`
|
||||
injection → `Screen::handleInputEvent` → frame transition). Parametrized
|
||||
on the screen-bearing role (heltec-v3 OLED). Captures images via USB
|
||||
webcam + OCRs them for HTML-report evidence. Requires `pip install -e '.[ui]'`
|
||||
and `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=<index>`; tier is auto-deselected
|
||||
if `cv2` isn't importable.
|
||||
- **`fleet`** — PSK-seed isolation: two labs with different seeds never
|
||||
overlap.
|
||||
- **`admin`** — owner persistence across reboot, channel URL round-trip,
|
||||
@@ -193,6 +233,42 @@ in the pre-flight header.
|
||||
- **`provisioning`** — region/channel baking, userPrefs survive
|
||||
`factory_reset(full=False)`.
|
||||
|
||||
#### UI tier setup
|
||||
|
||||
The `tests/ui/` tier drives the on-device OLED via the firmware's existing
|
||||
`AdminMessage.send_input_event` RPC (no firmware changes required) and
|
||||
verifies transitions via a macro-gated log line + camera + OCR. Summary:
|
||||
|
||||
1. Install extras: `pip install -e 'mcp-server/.[ui]'` — pulls in
|
||||
`opencv-python-headless`, `numpy`, `easyocr`, `Pillow`. First easyocr
|
||||
run downloads ~100 MB of models to `~/.EasyOCR/`; an autouse session
|
||||
fixture pre-warms the reader so per-test OCR is <100 ms after that.
|
||||
2. Point a USB webcam at the heltec-v3 OLED. Discover its index:
|
||||
```bash
|
||||
.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"
|
||||
```
|
||||
3. Export the per-role device env var:
|
||||
```bash
|
||||
export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0
|
||||
```
|
||||
4. Run:
|
||||
```bash
|
||||
./run-tests.sh tests/ui -v
|
||||
```
|
||||
Captures land under `tests/ui_captures/<session_seed>/<test_id>/`, one
|
||||
PNG + `.ocr.txt` per `frame_capture()` call, with a per-test
|
||||
`transcript.md` stepping through event → frame → OCR. The HTML report
|
||||
embeds the full image strip inline (pass or fail).
|
||||
|
||||
On macOS, `cv2.VideoCapture(0)` triggers the TCC Camera permission prompt
|
||||
on first use. Pre-grant Terminal (or your IDE's terminal) before running.
|
||||
The `OpenCVBackend` fails fast on 10 consecutive black frames so a silent
|
||||
permission denial surfaces as a clear error, not an empty PNG strip.
|
||||
|
||||
No camera? Set `MESHTASTIC_UI_CAMERA_BACKEND=null` (or leave the device var
|
||||
unset). Tests still exercise the event-injection path and log assertions;
|
||||
captures just become 1×1 black PNGs.
|
||||
|
||||
### Artifacts (regenerated every run, under `tests/`)
|
||||
|
||||
- `report.html` — self-contained pytest-html report. Each test gets a
|
||||
@@ -217,6 +293,14 @@ Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
|
||||
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
|
||||
quit (SIGINT → SIGTERM → SIGKILL escalation).
|
||||
|
||||
Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera**
|
||||
panel. Left side: the latest capture PNG rendered as Unicode half-blocks
|
||||
(via `rich-pixels`, works in any terminal — no kitty/sixel required).
|
||||
Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes
|
||||
— OCR: Nodes 2/2") so you can see every event-injection and its result
|
||||
as each UI test runs. Requires the `[ui]` extras for image rendering; the
|
||||
transcript alone works without them.
|
||||
|
||||
### Slash commands
|
||||
|
||||
Three AI-assisted workflows are wired up for Claude Code operators
|
||||
|
||||
@@ -23,6 +23,21 @@ test = [
|
||||
# consumers; revisit if install cost pushes back.
|
||||
"textual>=0.50",
|
||||
]
|
||||
# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR
|
||||
# model alone is ~100 MB and camera hardware is user-supplied.
|
||||
# pip install -e '.[ui]' — full (OpenCV + easyocr)
|
||||
# pip install -e '.[ui-min]' — image capture only, no OCR
|
||||
ui = [
|
||||
"opencv-python-headless>=4.9",
|
||||
"numpy>=1.26",
|
||||
"easyocr>=1.7",
|
||||
"Pillow>=10.0",
|
||||
# Renders the latest camera capture as Unicode half-blocks in the TUI
|
||||
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel
|
||||
# dependency. Pure Python, tiny.
|
||||
"rich-pixels>=3.0",
|
||||
]
|
||||
ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"]
|
||||
|
||||
[project.scripts]
|
||||
meshtastic-mcp = "meshtastic_mcp.__main__:main"
|
||||
|
||||
@@ -217,6 +217,40 @@ if [[ $# -eq 0 ]]; then
|
||||
-v --tb=short
|
||||
fi
|
||||
|
||||
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
|
||||
# not installed, auto-deselect tests/ui so operators without the [ui]
|
||||
# extra still get a green run. Printed in yellow; silent when cv2 is
|
||||
# present.
|
||||
_cv2_ok=0
|
||||
if "$VENV_PY" -c "import cv2" >/dev/null 2>&1; then
|
||||
_cv2_ok=1
|
||||
fi
|
||||
_running_ui=0
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
*tests/ui* | tests/) _running_ui=1 ;;
|
||||
*) ;;
|
||||
esac
|
||||
done
|
||||
if [[ $_running_ui -eq 1 && $_cv2_ok -eq 0 ]]; then
|
||||
printf '\033[33m[pre-flight] tests/ui tier detected, but opencv-python-headless is not installed — deselecting.\033[0m\n'
|
||||
printf ' install with: .venv/bin/pip install -e "mcp-server/.[ui]"\n'
|
||||
echo
|
||||
set -- "$@" --ignore=tests/ui
|
||||
fi
|
||||
|
||||
# Recovery tier needs `uhubctl` on PATH — it power-cycles devices via USB
|
||||
# hub PPPS. The tier's conftest already skips cleanly, so this is just a
|
||||
# friendly heads-up before the skip happens. `baked_single`'s auto-
|
||||
# recovery hook also benefits from having uhubctl available across the
|
||||
# whole suite.
|
||||
if ! command -v uhubctl >/dev/null 2>&1; then
|
||||
printf "\033[33m[pre-flight] uhubctl not found on PATH — recovery tier will skip, and\n"
|
||||
printf " wedged-device auto-recovery is disabled.\033[0m\n"
|
||||
printf " install with: brew install uhubctl (macOS) or apt install uhubctl (Debian/Ubuntu).\n"
|
||||
echo
|
||||
fi
|
||||
|
||||
# Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed
|
||||
# their own `--report-log=...`). Consumers — notably the
|
||||
# `meshtastic-mcp-test-tui` TUI — tail the reportlog for live per-test state.
|
||||
|
||||
@@ -356,6 +356,46 @@ def shutdown(
|
||||
return {"ok": True, "shutting_down_in_s": seconds}
|
||||
|
||||
|
||||
def send_input_event(
|
||||
event_code: int | str,
|
||||
kb_char: int = 0,
|
||||
touch_x: int = 0,
|
||||
touch_y: int = 0,
|
||||
port: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Inject an InputBroker event (button press / key / gesture) into the UI.
|
||||
|
||||
Wraps `AdminMessage.send_input_event` (handled in firmware at
|
||||
src/modules/AdminModule.cpp::handleSendInputEvent). Local-only — no PKI
|
||||
warmup needed since the admin message is addressed to `my_node_num`.
|
||||
|
||||
`event_code` accepts an int, a case-insensitive name
|
||||
(`"RIGHT"` / `"input_broker_right"`), or an `InputEventCode`. The
|
||||
firmware-side enum lives in src/input/InputBroker.h and is mirrored in
|
||||
`meshtastic_mcp.input_events`.
|
||||
"""
|
||||
from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped]
|
||||
|
||||
from .input_events import coerce_event_code
|
||||
|
||||
code = coerce_event_code(event_code)
|
||||
if not 0 <= kb_char <= 255:
|
||||
raise ValueError(f"kb_char out of u8 range: {kb_char}")
|
||||
if not 0 <= touch_x <= 65535:
|
||||
raise ValueError(f"touch_x out of u16 range: {touch_x}")
|
||||
if not 0 <= touch_y <= 65535:
|
||||
raise ValueError(f"touch_y out of u16 range: {touch_y}")
|
||||
|
||||
with connect(port=port) as iface:
|
||||
msg = admin_pb2.AdminMessage()
|
||||
msg.send_input_event.event_code = code
|
||||
msg.send_input_event.kb_char = kb_char
|
||||
msg.send_input_event.touch_x = touch_x
|
||||
msg.send_input_event.touch_y = touch_y
|
||||
iface.localNode._sendAdmin(msg)
|
||||
return {"ok": True, "event_code": code, "kb_char": kb_char}
|
||||
|
||||
|
||||
def factory_reset(
|
||||
port: str | None = None, confirm: bool = False, full: bool = False
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Cross-platform USB-webcam capture for UI tests + the `capture_screen` tool.
|
||||
|
||||
Backends:
|
||||
- `opencv` — cv2.VideoCapture (AVFoundation on macOS, V4L2 on Linux).
|
||||
- `ffmpeg` — subprocess shelling out to the system `ffmpeg` binary. Slower
|
||||
per frame, but zero Python deps beyond stdlib.
|
||||
- `null` — no-op stub returning a 1×1 black PNG. Used when no camera is
|
||||
configured; keeps code paths alive without forcing every operator to
|
||||
hook up hardware.
|
||||
|
||||
Environment variables (read at `get_camera()` call time):
|
||||
- `MESHTASTIC_UI_CAMERA_BACKEND` — one of `opencv` / `ffmpeg` / `null` /
|
||||
`auto` (default). `auto` picks opencv if `cv2` imports, else ffmpeg if
|
||||
`ffmpeg --version` resolves, else null.
|
||||
- `MESHTASTIC_UI_CAMERA_DEVICE` — generic default (index or path).
|
||||
- `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` — per-role override, e.g.
|
||||
`MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3.
|
||||
Role suffix is uppercased before lookup.
|
||||
|
||||
Dependencies land in the optional `[ui]` extra; imports are lazy so clients
|
||||
without `opencv-python-headless` installed can still import this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class CameraError(RuntimeError):
|
||||
"""Raised when a camera backend fails to initialize or capture."""
|
||||
|
||||
|
||||
class CameraBackend(Protocol):
|
||||
name: str
|
||||
|
||||
def capture(self) -> bytes:
|
||||
"""Return one PNG-encoded frame."""
|
||||
...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
# ---------- OpenCV backend -------------------------------------------------
|
||||
|
||||
|
||||
class OpenCVBackend:
|
||||
name = "opencv"
|
||||
|
||||
def __init__(self, device: int | str, warmup_frames: int = 5) -> None:
|
||||
try:
|
||||
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
|
||||
except ImportError as exc:
|
||||
raise CameraError(
|
||||
"opencv backend requested but `cv2` is not installed. "
|
||||
"Install the mcp-server [ui] extra: pip install -e '.[ui]'"
|
||||
) from exc
|
||||
|
||||
self._cv2 = cv2
|
||||
device_arg: int | str
|
||||
if isinstance(device, str) and device.isdigit():
|
||||
device_arg = int(device)
|
||||
else:
|
||||
device_arg = device
|
||||
self._cap = cv2.VideoCapture(device_arg)
|
||||
if not self._cap.isOpened():
|
||||
raise CameraError(
|
||||
f"cv2.VideoCapture({device_arg!r}) failed to open. "
|
||||
"On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access."
|
||||
)
|
||||
|
||||
# Drop the first few frames — auto-exposure + white-balance settle.
|
||||
for _ in range(warmup_frames):
|
||||
self._cap.read()
|
||||
# Detect a stuck black-frame camera early rather than silently
|
||||
# producing all-black captures.
|
||||
ok, frame = self._cap.read()
|
||||
if not ok or frame is None:
|
||||
self._cap.release()
|
||||
raise CameraError(f"camera {device_arg!r} opened but returned no frames")
|
||||
|
||||
def capture(self) -> bytes:
|
||||
cv2 = self._cv2
|
||||
ok, frame = self._cap.read()
|
||||
if not ok or frame is None:
|
||||
raise CameraError("cv2.VideoCapture.read() returned no frame")
|
||||
success, buf = cv2.imencode(".png", frame)
|
||||
if not success:
|
||||
raise CameraError("cv2.imencode('.png', ...) failed")
|
||||
return bytes(buf)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._cap.release()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
# ---------- ffmpeg subprocess backend --------------------------------------
|
||||
|
||||
|
||||
class FfmpegBackend:
|
||||
name = "ffmpeg"
|
||||
|
||||
def __init__(self, device: int | str) -> None:
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise CameraError("ffmpeg backend requested but `ffmpeg` is not on PATH")
|
||||
|
||||
self._device = str(device)
|
||||
# Platform-specific -f flag:
|
||||
# macOS → avfoundation (index like "0")
|
||||
# Linux → v4l2 (device like "/dev/video0" or "0")
|
||||
if sys.platform == "darwin":
|
||||
self._input_format = "avfoundation"
|
||||
self._input_spec = self._device # bare index for avfoundation
|
||||
else:
|
||||
self._input_format = "v4l2"
|
||||
self._input_spec = (
|
||||
self._device
|
||||
if self._device.startswith("/dev/")
|
||||
else f"/dev/video{self._device}"
|
||||
)
|
||||
|
||||
def capture(self) -> bytes:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
self._input_format,
|
||||
"-i",
|
||||
self._input_spec,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"-",
|
||||
]
|
||||
try:
|
||||
out = subprocess.run(
|
||||
cmd, capture_output=True, check=True, timeout=15 # noqa: S603
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CameraError(
|
||||
f"ffmpeg capture failed (rc={exc.returncode}): {exc.stderr.decode(errors='replace')[:200]}"
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise CameraError("ffmpeg capture timed out after 15s") from exc
|
||||
return out.stdout
|
||||
|
||||
def close(self) -> None:
|
||||
pass # stateless — each capture spawns a new process
|
||||
|
||||
|
||||
# ---------- Null backend ---------------------------------------------------
|
||||
|
||||
|
||||
# A tiny valid 1×1 transparent PNG so callers always get a decodable image.
|
||||
_BLACK_1X1_PNG = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082"
|
||||
)
|
||||
|
||||
|
||||
class NullBackend:
|
||||
name = "null"
|
||||
|
||||
def capture(self) -> bytes:
|
||||
return _BLACK_1X1_PNG
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- Factory --------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_device(role: str | None) -> str | None:
|
||||
if role:
|
||||
specific = os.environ.get(f"MESHTASTIC_UI_CAMERA_DEVICE_{role.upper()}")
|
||||
if specific:
|
||||
return specific
|
||||
return os.environ.get("MESHTASTIC_UI_CAMERA_DEVICE")
|
||||
|
||||
|
||||
def get_camera(role: str | None = None) -> CameraBackend:
|
||||
"""Return a CameraBackend for the given device role (e.g. `"esp32s3"`).
|
||||
|
||||
Falls back to `NullBackend` if no camera is configured or the selected
|
||||
backend fails to init — tests should treat captures as best-effort
|
||||
evidence, not a blocker.
|
||||
"""
|
||||
backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower()
|
||||
device = _resolve_device(role)
|
||||
|
||||
if backend in ("null", "none") or device is None:
|
||||
return NullBackend()
|
||||
|
||||
if backend == "auto":
|
||||
# Prefer opencv if importable; fall back to ffmpeg; else null.
|
||||
try:
|
||||
import cv2 # type: ignore[import-untyped] # noqa: F401,PLC0415
|
||||
|
||||
backend = "opencv"
|
||||
except ImportError:
|
||||
backend = "ffmpeg" if shutil.which("ffmpeg") else "null"
|
||||
|
||||
if backend == "opencv":
|
||||
try:
|
||||
return OpenCVBackend(device)
|
||||
except CameraError as exc:
|
||||
warnings.warn(
|
||||
f"camera backend {backend!r} failed to initialize for device "
|
||||
f"{device!r}: {exc}; falling back to null backend",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return NullBackend()
|
||||
if backend == "ffmpeg":
|
||||
try:
|
||||
return FfmpegBackend(device)
|
||||
except CameraError as exc:
|
||||
warnings.warn(
|
||||
f"camera backend {backend!r} failed to initialize for device "
|
||||
f"{device!r}: {exc}; falling back to null backend",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return NullBackend()
|
||||
if backend == "null":
|
||||
return NullBackend()
|
||||
|
||||
raise CameraError(f"unknown MESHTASTIC_UI_CAMERA_BACKEND: {backend!r}")
|
||||
|
||||
|
||||
def save_capture(png_bytes: bytes, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(png_bytes)
|
||||
|
||||
|
||||
def capture_to_file(role: str | None, path: Path) -> dict[str, object]:
|
||||
"""One-shot: open camera, capture, write PNG, close. Returns metadata."""
|
||||
started = time.monotonic()
|
||||
cam = get_camera(role)
|
||||
try:
|
||||
data = cam.capture()
|
||||
finally:
|
||||
cam.close()
|
||||
save_capture(data, path)
|
||||
return {
|
||||
"backend": cam.name,
|
||||
"path": str(path),
|
||||
"bytes": len(data),
|
||||
"elapsed_s": round(time.monotonic() - started, 3),
|
||||
}
|
||||
|
||||
|
||||
def _is_png(data: bytes) -> bool:
|
||||
return data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
# Exposed so callers can sanity-check a capture without a full PIL import.
|
||||
__all__ = [
|
||||
"CameraBackend",
|
||||
"CameraError",
|
||||
"FfmpegBackend",
|
||||
"NullBackend",
|
||||
"OpenCVBackend",
|
||||
"capture_to_file",
|
||||
"get_camera",
|
||||
"save_capture",
|
||||
]
|
||||
|
||||
# Keep `io` import used (pyflakes is picky) via a small guard used at import
|
||||
# time to normalize stdin/stdout if a subclass ever needs it.
|
||||
_ = io.BytesIO # noqa: SLF001
|
||||
@@ -0,0 +1,83 @@
|
||||
"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``.
|
||||
|
||||
Watches ``tests/ui_captures/<session_seed>/`` for new transcript lines
|
||||
(one per ``frame_capture()`` call from the UI tier) and posts them to
|
||||
the TUI. Enabled by ``MESHTASTIC_UI_TUI_CAMERA=1``.
|
||||
|
||||
Design mirrors ``_flashlog.py``:
|
||||
- Daemon thread, cooperative stop via ``threading.Event``.
|
||||
- Tolerates the captures directory not existing yet (UI tier hasn't run).
|
||||
- Per-file seek state so we only forward genuinely-new lines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class UiCaptureTailer(threading.Thread):
|
||||
"""Recursively watch a captures root for new `transcript.md` lines.
|
||||
|
||||
Invokes ``post(test_id, line)`` for each new line, where ``test_id``
|
||||
is derived from the path — the sanitized nodeid directory name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: pathlib.Path,
|
||||
post: Callable[[str, str], None],
|
||||
stop: threading.Event,
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name="uicap-tail")
|
||||
self._root = root
|
||||
self._post = post
|
||||
self._stop = stop
|
||||
self._poll_interval = poll_interval
|
||||
# path → byte offset we've already read through
|
||||
self._offsets: dict[pathlib.Path, int] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self._scan_once()
|
||||
except Exception:
|
||||
# Best-effort tailer — never bring down the TUI because a
|
||||
# directory vanished mid-scan.
|
||||
pass
|
||||
time.sleep(self._poll_interval)
|
||||
|
||||
def _scan_once(self) -> None:
|
||||
if not self._root.is_dir():
|
||||
return
|
||||
for transcript in self._root.rglob("transcript.md"):
|
||||
test_id = transcript.parent.name
|
||||
offset = self._offsets.get(transcript, 0)
|
||||
try:
|
||||
size = transcript.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
if size < offset:
|
||||
# File truncated / rewritten — reset and re-emit.
|
||||
offset = 0
|
||||
if size == offset:
|
||||
continue
|
||||
try:
|
||||
with transcript.open("rb") as fh:
|
||||
fh.seek(offset)
|
||||
chunk = fh.read(size - offset).decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for line in chunk.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
self._post(test_id, line)
|
||||
except Exception:
|
||||
return
|
||||
self._offsets[transcript] = size
|
||||
@@ -518,6 +518,7 @@ def _build_app(
|
||||
from . import _fwlog as _fwlog_mod
|
||||
from . import _history as _history_mod
|
||||
from . import _reproducer as _reproducer_mod
|
||||
from . import _uicap as _uicap_mod
|
||||
|
||||
# ---------------- Messages ----------------
|
||||
|
||||
@@ -548,6 +549,16 @@ def _build_app(
|
||||
self.line = line
|
||||
super().__init__()
|
||||
|
||||
class UiCaptureLine(tx.Message):
|
||||
"""Live line from the UI-tier camera transcript — one per
|
||||
`frame_capture()` call. Posted only when the camera panel is
|
||||
enabled via `MESHTASTIC_UI_TUI_CAMERA=1`."""
|
||||
|
||||
def __init__(self, test_id: str, line: str) -> None:
|
||||
self.test_id = test_id
|
||||
self.line = line
|
||||
super().__init__()
|
||||
|
||||
class DeviceSnapshot(tx.Message):
|
||||
def __init__(self, rows: list[DeviceRow]) -> None:
|
||||
self.rows = rows
|
||||
@@ -871,6 +882,10 @@ def _build_app(
|
||||
#pytest-pane { height: 50%; border-bottom: solid $primary-background; }
|
||||
#fwlog-header { height: 1; padding: 0 1; background: $panel; }
|
||||
#fwlog-pane { height: 1fr; }
|
||||
#uicap-header { height: 1; padding: 0 1; background: $boost; }
|
||||
#uicap-pane { height: 14; border-top: solid $primary-background; }
|
||||
#uicap-image { width: 36; border-right: solid $primary-background; padding: 0 1; }
|
||||
#uicap-log { width: 1fr; height: 14; }
|
||||
Tree { height: 100%; }
|
||||
RichLog { height: 100%; }
|
||||
#device-table { height: auto; max-height: 6; }
|
||||
@@ -912,6 +927,11 @@ def _build_app(
|
||||
self._device_worker: DevicePollerWorker | None = None
|
||||
self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None
|
||||
self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None
|
||||
self._uicap_worker: _uicap_mod.UiCaptureTailer | None = None
|
||||
# Env-gated; only mounts the UI-capture panel when operator asks for it.
|
||||
self._ui_camera_enabled = bool(
|
||||
int(os.environ.get("MESHTASTIC_UI_TUI_CAMERA", "0") or "0")
|
||||
)
|
||||
self._tree_filter: str = ""
|
||||
self._sigint_count = 0
|
||||
# Firmware-log port filter: None = all, else exact port match.
|
||||
@@ -959,6 +979,22 @@ def _build_app(
|
||||
wrap=True,
|
||||
max_lines=5000,
|
||||
)
|
||||
if self._ui_camera_enabled:
|
||||
yield tx.Static(
|
||||
"UI camera — latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
|
||||
id="uicap-header",
|
||||
)
|
||||
with tx.Horizontal(id="uicap-pane"):
|
||||
yield tx.Static(
|
||||
"(waiting…)", id="uicap-image", markup=False
|
||||
)
|
||||
yield tx.RichLog(
|
||||
id="uicap-log",
|
||||
highlight=False,
|
||||
markup=False,
|
||||
wrap=True,
|
||||
max_lines=500,
|
||||
)
|
||||
yield tx.DataTable(id="device-table", show_cursor=False)
|
||||
yield tx.Footer()
|
||||
|
||||
@@ -1023,6 +1059,21 @@ def _build_app(
|
||||
stop=self._stop,
|
||||
)
|
||||
self._flashlog_worker.start()
|
||||
# UI-capture transcript tailer — only runs when the camera panel
|
||||
# is enabled. Watches tests/ui_captures/**/transcript.md for new
|
||||
# lines as UI tests execute.
|
||||
if self._ui_camera_enabled:
|
||||
captures_root = self._root / "mcp-server" / "tests" / "ui_captures"
|
||||
# When the TUI is launched from inside mcp-server (the usual
|
||||
# case), `self._root` is already mcp-server/, so adjust:
|
||||
if not captures_root.parent.name == "mcp-server":
|
||||
captures_root = self._root / "tests" / "ui_captures"
|
||||
self._uicap_worker = _uicap_mod.UiCaptureTailer(
|
||||
root=captures_root,
|
||||
post=lambda tid, line: self.post_message(UiCaptureLine(tid, line)),
|
||||
stop=self._stop,
|
||||
)
|
||||
self._uicap_worker.start()
|
||||
self._spawn_pytest(self._pytest_args)
|
||||
# Header tick (seed / runtime / sparkline re-renders at 1 Hz).
|
||||
# Also refreshes the device-status column so the per-test elapsed
|
||||
@@ -1217,6 +1268,84 @@ def _build_app(
|
||||
log = self.query_one("#pytest-log", tx.RichLog)
|
||||
log.write(f"[flash] {message.line}")
|
||||
|
||||
def on_ui_capture_line(self, message: Any) -> None:
|
||||
"""Route a UI-capture transcript line into the camera panel.
|
||||
|
||||
Each line is already formatted by frame_capture — e.g.
|
||||
`1. **initial** — frame 2/8 name=home — OCR: ...`. We write
|
||||
the text into the RichLog AND try to render the corresponding
|
||||
PNG on the left side (requires rich-pixels, Pillow).
|
||||
"""
|
||||
if not self._ui_camera_enabled:
|
||||
return
|
||||
try:
|
||||
log_panel = self.query_one("#uicap-log", tx.RichLog)
|
||||
except Exception:
|
||||
return
|
||||
log_panel.write(f"[{message.test_id}] {message.line}")
|
||||
self._render_latest_ui_capture(message.test_id, message.line)
|
||||
|
||||
def _render_latest_ui_capture(self, test_id: str, line: str) -> None:
|
||||
"""Find the PNG that corresponds to `line` and render it on the
|
||||
left of the uicap pane. Soft-fails if rich-pixels isn't
|
||||
installed or the PNG isn't found — operator still has the text
|
||||
transcript on the right.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image # type: ignore[import-untyped]
|
||||
from rich_pixels import Pixels # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Transcript lines look like `1. **label** — ...`. Pull the leading
|
||||
# integer to locate the capture file.
|
||||
import re as _re
|
||||
|
||||
m = _re.match(r"\s*(\d+)\.\s", line)
|
||||
if not m:
|
||||
return
|
||||
step = int(m.group(1))
|
||||
|
||||
# Captures directory is sibling of tests/ — mirror the path the
|
||||
# tailer watches. Search both likely layouts (in-mcp-server vs.
|
||||
# firmware-root invocation).
|
||||
candidates = [
|
||||
self._root / "tests" / "ui_captures",
|
||||
self._root / "mcp-server" / "tests" / "ui_captures",
|
||||
]
|
||||
captures_root = next((p for p in candidates if p.is_dir()), None)
|
||||
if captures_root is None:
|
||||
return
|
||||
|
||||
# Drill into <session_seed>/<test_id>/ — test_id is the
|
||||
# sanitized nodeid the tailer already passed through.
|
||||
matches = list(captures_root.rglob(f"{test_id}/{step:03d}-*.png"))
|
||||
if not matches:
|
||||
return
|
||||
png_path = matches[-1]
|
||||
|
||||
try:
|
||||
img = Image.open(png_path).convert("RGB")
|
||||
# Resize to fit ~32 cells wide × ~12 rows tall (half-block
|
||||
# renderer gives 2× vertical resolution, so 32×24 px input
|
||||
# lands at ~32×12 cells). Keep aspect ratio.
|
||||
target_w = 60
|
||||
w, h = img.size
|
||||
target_h = max(1, int(h * (target_w / max(1, w))))
|
||||
# Clamp: the image panel is 14 rows; half-blocks give 2 rows
|
||||
# per vertical cell, so cap pixel height at ~26.
|
||||
target_h = min(target_h, 26)
|
||||
img = img.resize((target_w, target_h))
|
||||
pixels = Pixels.from_image(img)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
image_widget = self.query_one("#uicap-image", tx.Static)
|
||||
image_widget.update(pixels)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_firmware_log_line(self, message: Any) -> None:
|
||||
rec = message.record
|
||||
port = rec.get("port")
|
||||
|
||||
@@ -135,3 +135,14 @@ def picotool_bin() -> Path:
|
||||
("picotool",),
|
||||
"Install via `brew install picotool` or build from https://github.com/raspberrypi/picotool.",
|
||||
)
|
||||
|
||||
|
||||
def uhubctl_bin() -> Path:
|
||||
return _hw_tool(
|
||||
"MESHTASTIC_UHUBCTL_BIN",
|
||||
("uhubctl",),
|
||||
"Install via `brew install uhubctl` (macOS) or `apt install uhubctl` "
|
||||
"(Debian/Ubuntu). On Linux without the udev rules, or on older macOS "
|
||||
"with certain hubs, you may need to run via `sudo`: "
|
||||
"https://github.com/mvp/uhubctl#linux-usb-permissions",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Python mirror of firmware `enum input_broker_event` (src/input/InputBroker.h).
|
||||
|
||||
Used by `admin.send_input_event` + `tests/ui/` so callers can say
|
||||
`InputEventCode.RIGHT` instead of hard-coding 20. Values MUST stay in sync
|
||||
with the firmware enum — unit test `tests/unit/test_input_event_codes.py`
|
||||
pins the mapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class InputEventCode(IntEnum):
|
||||
"""Button / key / gesture events dispatched by the firmware InputBroker."""
|
||||
|
||||
NONE = 0
|
||||
SELECT = 10
|
||||
SELECT_LONG = 11
|
||||
UP_LONG = 12
|
||||
DOWN_LONG = 13
|
||||
UP = 17
|
||||
DOWN = 18
|
||||
LEFT = 19
|
||||
RIGHT = 20
|
||||
CANCEL = 24
|
||||
BACK = 27
|
||||
# Auto-incremented values in the C enum (27 + 1, +2, +3):
|
||||
USER_PRESS = 28
|
||||
ALT_PRESS = 29
|
||||
ALT_LONG = 30
|
||||
SHUTDOWN = 0x9B
|
||||
GPS_TOGGLE = 0x9E
|
||||
SEND_PING = 0xAF
|
||||
FN_F1 = 0xF1
|
||||
FN_F2 = 0xF2
|
||||
FN_F3 = 0xF3
|
||||
FN_F4 = 0xF4
|
||||
FN_F5 = 0xF5
|
||||
MATRIXKEY = 0xFE
|
||||
ANYKEY = 0xFF
|
||||
|
||||
|
||||
def coerce_event_code(value: int | str | InputEventCode) -> int:
|
||||
"""Accept an int, a case-insensitive name, or an `InputEventCode` and return
|
||||
the u8 wire value. Raises ValueError on unknown names / out-of-range ints.
|
||||
"""
|
||||
if isinstance(value, InputEventCode):
|
||||
return int(value)
|
||||
if isinstance(value, int):
|
||||
if not 0 <= value <= 255:
|
||||
raise ValueError(f"event_code out of u8 range: {value}")
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
key = value.upper().replace("-", "_")
|
||||
if key.startswith("INPUT_BROKER_"):
|
||||
key = key[len("INPUT_BROKER_") :]
|
||||
try:
|
||||
return int(InputEventCode[key])
|
||||
except KeyError as exc:
|
||||
known = ", ".join(m.name for m in InputEventCode)
|
||||
raise ValueError(
|
||||
f"unknown event code name {value!r}; known: {known}"
|
||||
) from exc
|
||||
raise TypeError(
|
||||
f"event_code must be int|str|InputEventCode, got {type(value).__name__}"
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""OCR wrapper for UI tests + the `capture_screen` tool.
|
||||
|
||||
Auto-selects a reader in priority order:
|
||||
1. `easyocr` (deep-learning, high quality on OLED screens — but ~100 MB
|
||||
model download on first use).
|
||||
2. `pytesseract` (requires system `tesseract` binary on PATH).
|
||||
3. `null` — returns `""` with a warning. Tests fall back to log + image
|
||||
evidence when OCR is unavailable.
|
||||
|
||||
Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto`
|
||||
(default `auto`).
|
||||
|
||||
`ocr_text(png_bytes) -> str` is the only public entry point. The reader is
|
||||
constructed lazily on first call and cached, so the easyocr cold-start cost
|
||||
only hits once per process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _backend_choice() -> str:
|
||||
return os.environ.get("MESHTASTIC_UI_OCR_BACKEND", "auto").lower()
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _reader() -> tuple[str, Callable[[bytes], str]]:
|
||||
"""Return `(backend_name, callable)` for whichever OCR is available."""
|
||||
choice = _backend_choice()
|
||||
|
||||
def _easyocr() -> tuple[str, Callable[[bytes], str]]:
|
||||
import easyocr # type: ignore[import-untyped] # noqa: PLC0415
|
||||
import numpy as np # type: ignore[import-untyped] # noqa: PLC0415
|
||||
|
||||
reader = easyocr.Reader(["en"], gpu=False, verbose=False)
|
||||
|
||||
def _run(png: bytes) -> str:
|
||||
try:
|
||||
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
|
||||
|
||||
arr = np.frombuffer(png, dtype=np.uint8)
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
except ImportError:
|
||||
# Fall back to PIL if cv2 isn't around.
|
||||
from io import BytesIO # noqa: PLC0415
|
||||
|
||||
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
|
||||
|
||||
img = np.array(Image.open(BytesIO(png)).convert("RGB"))
|
||||
try:
|
||||
results = reader.readtext(img, detail=0, paragraph=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("easyocr failed: %s", exc)
|
||||
return ""
|
||||
return "\n".join(str(r) for r in results)
|
||||
|
||||
return "easyocr", _run
|
||||
|
||||
def _pytesseract() -> tuple[str, Callable[[bytes], str]]:
|
||||
from io import BytesIO # noqa: PLC0415
|
||||
|
||||
import pytesseract # type: ignore[import-untyped] # noqa: PLC0415
|
||||
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
|
||||
|
||||
if shutil.which("tesseract") is None:
|
||||
raise ImportError("`tesseract` binary not on PATH")
|
||||
|
||||
def _run(png: bytes) -> str:
|
||||
try:
|
||||
return str(pytesseract.image_to_string(Image.open(BytesIO(png))))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("pytesseract failed: %s", exc)
|
||||
return ""
|
||||
|
||||
return "pytesseract", _run
|
||||
|
||||
def _null() -> tuple[str, Callable[[bytes], str]]:
|
||||
log.warning(
|
||||
"OCR backend is null; install easyocr or tesseract for text extraction"
|
||||
)
|
||||
return "null", lambda _png: ""
|
||||
|
||||
if choice == "easyocr":
|
||||
return _easyocr()
|
||||
if choice == "pytesseract":
|
||||
return _pytesseract()
|
||||
if choice == "null":
|
||||
return _null()
|
||||
if choice != "auto":
|
||||
print(
|
||||
f"[ocr] unknown MESHTASTIC_UI_OCR_BACKEND={choice!r}; falling back to auto",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# auto mode
|
||||
try:
|
||||
return _easyocr()
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
return _pytesseract()
|
||||
except ImportError:
|
||||
pass
|
||||
return _null()
|
||||
|
||||
|
||||
def ocr_text(png_bytes: bytes) -> str:
|
||||
"""Run OCR on a PNG-encoded image and return the decoded text (possibly empty)."""
|
||||
if not png_bytes:
|
||||
return ""
|
||||
_, run = _reader()
|
||||
return run(png_bytes)
|
||||
|
||||
|
||||
def backend_name() -> str:
|
||||
"""Return the currently-selected backend name, initializing if necessary."""
|
||||
name, _ = _reader()
|
||||
return name
|
||||
|
||||
|
||||
def warm() -> None:
|
||||
"""Run one dummy inference so the easyocr cold-start cost is paid upfront.
|
||||
|
||||
Pytest session fixture calls this once so the first real capture doesn't
|
||||
eat the model-load latency.
|
||||
"""
|
||||
# A 64×32 white PNG — decodes clean, no text to extract.
|
||||
white_png = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e"
|
||||
"0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080"
|
||||
"0b010000ffff030000000000000049454e44ae426082"
|
||||
)
|
||||
try:
|
||||
ocr_text(white_png)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("ocr.warm() failed: %s", exc)
|
||||
|
||||
|
||||
__all__ = ["backend_name", "ocr_text", "warm"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastMCP server wiring — 38 tools across 7 categories.
|
||||
"""FastMCP server wiring — 43 tools across 9 categories (adds uhubctl power control).
|
||||
|
||||
Each tool handler is a thin delegation to a named module (pio.py, admin.py,
|
||||
etc.). Business logic does not live here.
|
||||
@@ -513,6 +513,152 @@ def factory_reset(
|
||||
return admin.factory_reset(port=port, confirm=confirm, full=full)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def send_input_event(
|
||||
event_code: int | str,
|
||||
kb_char: int = 0,
|
||||
touch_x: int = 0,
|
||||
touch_y: int = 0,
|
||||
port: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Inject an InputBroker event (button / key / gesture) into the device UI.
|
||||
|
||||
Drives the same code path as a physical button press. Accepts a numeric
|
||||
event code (0..255) or a name like `"RIGHT"`, `"SELECT"`, `"FN_F1"`.
|
||||
|
||||
Common codes: SELECT=10, UP=17, DOWN=18, LEFT=19, RIGHT=20, CANCEL=24,
|
||||
BACK=27, FN_F1..F5=241..245.
|
||||
"""
|
||||
return admin.send_input_event(
|
||||
event_code=event_code,
|
||||
kb_char=kb_char,
|
||||
touch_x=touch_x,
|
||||
touch_y=touch_y,
|
||||
port=port,
|
||||
)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]:
|
||||
"""Grab a frame from the USB webcam pointed at the device screen.
|
||||
|
||||
Returns PNG bytes (base64), optional OCR text, and backend metadata.
|
||||
Requires the `[ui]` extras (opencv-python-headless) and a camera
|
||||
configured via `MESHTASTIC_UI_CAMERA_DEVICE[_<ROLE>]`. Falls back to a
|
||||
1×1 black PNG from the null backend when no camera is configured.
|
||||
"""
|
||||
import base64
|
||||
|
||||
from . import camera as camera_mod
|
||||
|
||||
cam = camera_mod.get_camera(role)
|
||||
try:
|
||||
png = cam.capture()
|
||||
finally:
|
||||
cam.close()
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"backend": cam.name,
|
||||
"bytes": len(png),
|
||||
"image_base64": base64.b64encode(png).decode("ascii"),
|
||||
}
|
||||
if ocr:
|
||||
from . import ocr as ocr_mod
|
||||
|
||||
result["ocr_backend"] = ocr_mod.backend_name()
|
||||
result["ocr_text"] = ocr_mod.ocr_text(png)
|
||||
return result
|
||||
|
||||
|
||||
# ---------- USB power control (uhubctl) -----------------------------------
|
||||
|
||||
|
||||
@app.tool()
|
||||
def uhubctl_list() -> list[dict[str, Any]]:
|
||||
"""List every USB hub + per-port device attachment as seen by `uhubctl`.
|
||||
|
||||
Read-only — no confirm required. Each hub entry includes its location
|
||||
(`1-1.3`), descriptor, whether it supports Per-Port Power Switching,
|
||||
and a list of populated ports with VID:PID of attached devices.
|
||||
Useful for pre-flight checks before a destructive power-cycle call.
|
||||
"""
|
||||
from . import uhubctl as uhubctl_mod
|
||||
|
||||
return uhubctl_mod.list_hubs()
|
||||
|
||||
|
||||
@app.tool()
|
||||
def uhubctl_power(
|
||||
action: str,
|
||||
location: str | None = None,
|
||||
port: int | None = None,
|
||||
role: str | None = None,
|
||||
confirm: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Power a USB hub port on or off via `uhubctl -a on|off`.
|
||||
|
||||
Target the port by either (`location`, `port`) — raw uhubctl syntax,
|
||||
e.g. `location="1-1.3", port=2` — OR by `role` ("nrf52", "esp32s3").
|
||||
Role lookup honors `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
|
||||
`_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
|
||||
|
||||
`action="off"` requires `confirm=True` (destructive — the attached
|
||||
device will immediately disappear from the OS).
|
||||
"""
|
||||
from . import uhubctl as uhubctl_mod
|
||||
|
||||
action_lower = action.lower()
|
||||
if action_lower not in {"on", "off"}:
|
||||
raise ValueError(f"action must be 'on' or 'off', got {action!r}")
|
||||
if action_lower == "off" and not confirm:
|
||||
raise uhubctl_mod.UhubctlError(
|
||||
"uhubctl_power action='off' requires confirm=True"
|
||||
)
|
||||
loc, p = _resolve_uhubctl_target(location, port, role)
|
||||
if action_lower == "on":
|
||||
return uhubctl_mod.power_on(loc, p)
|
||||
return uhubctl_mod.power_off(loc, p)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def uhubctl_cycle(
|
||||
location: str | None = None,
|
||||
port: int | None = None,
|
||||
role: str | None = None,
|
||||
delay_s: int = 2,
|
||||
confirm: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Power a USB hub port off, wait `delay_s` seconds, then on.
|
||||
|
||||
The typical hard-reset sequence — shorter than off+on as two RPCs
|
||||
because uhubctl handles the timing in-process. Target by (location,
|
||||
port) or by role (see `uhubctl_power`). Requires `confirm=True`.
|
||||
"""
|
||||
from . import uhubctl as uhubctl_mod
|
||||
|
||||
if not confirm:
|
||||
raise uhubctl_mod.UhubctlError("uhubctl_cycle requires confirm=True")
|
||||
if delay_s < 0 or delay_s > 60:
|
||||
raise ValueError(f"delay_s must be 0..60, got {delay_s}")
|
||||
loc, p = _resolve_uhubctl_target(location, port, role)
|
||||
return uhubctl_mod.cycle(loc, p, delay_s=delay_s)
|
||||
|
||||
|
||||
def _resolve_uhubctl_target(
|
||||
location: str | None, port: int | None, role: str | None
|
||||
) -> tuple[str, int]:
|
||||
"""Shared arg-resolution for uhubctl_power + uhubctl_cycle."""
|
||||
from . import uhubctl as uhubctl_mod
|
||||
|
||||
if role is not None:
|
||||
if location is not None or port is not None:
|
||||
raise ValueError("pass either `role` OR (`location` + `port`), not both")
|
||||
return uhubctl_mod.resolve_target(role)
|
||||
if location is None or port is None:
|
||||
raise ValueError("must pass `role` or both `location` and `port`")
|
||||
return (location, int(port))
|
||||
|
||||
|
||||
# ---------- Direct hardware tools -----------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""USB hub power control via `uhubctl` — hard-recovery for wedged devices +
|
||||
deliberate offline-peer simulation for mesh tests.
|
||||
|
||||
Why: when a Meshtastic device's serial port wedges (stuck in a boot loop,
|
||||
frozen USB CDC, crashed firmware that didn't reboot), the only recovery is
|
||||
a physical unplug. uhubctl toggles VBUS per-port on any hub with Per-Port
|
||||
Power Switching (PPPS) support — which is most externally-powered hubs
|
||||
from the last ~5 years — so the harness can power-cycle a device
|
||||
programmatically.
|
||||
|
||||
Architecture:
|
||||
- `list_hubs()` parses `uhubctl` default output into structured records.
|
||||
- `find_port_for_vid(vid)` walks the hubs to find which location+port
|
||||
hosts a given USB VID.
|
||||
- `resolve_target(role)` is the public entry for callers that know a role
|
||||
(`nrf52`, `esp32s3`) but not a hub location: env-var pins win, VID
|
||||
auto-detect falls back.
|
||||
- `power_on`, `power_off`, `cycle` wrap the corresponding `uhubctl -a`
|
||||
invocations, routed through `hw_tools._run` so they share tee-to-flash-
|
||||
log + timeout handling with esptool / nrfutil / picotool.
|
||||
|
||||
Sudo policy: **fail fast**. Modern macOS + most PPPS-capable hubs work
|
||||
without root, but Linux without udev rules (or old macOS with specific
|
||||
driver quirks) still needs it. We run uhubctl non-root; if stderr
|
||||
matches the classic permission pattern we raise `UhubctlError` with an
|
||||
install hint pointing at the uhubctl docs. Auto-wrapping with `sudo`
|
||||
would prompt in the middle of test runs — bad for CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Sequence
|
||||
|
||||
from . import config, hw_tools
|
||||
|
||||
# ---------- Parser ---------------------------------------------------------
|
||||
|
||||
# Hub descriptor line:
|
||||
# Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
|
||||
_HUB_RE = re.compile(
|
||||
r"^Current status for hub (?P<location>\S+)\s+\[(?P<descriptor>.+)\]\s*$"
|
||||
)
|
||||
|
||||
# Port line:
|
||||
# " Port 2: 0103 power enable connect [239a:8029 RAKwireless ...]"
|
||||
# The bracketed section is absent for empty ports.
|
||||
_PORT_RE = re.compile(
|
||||
r"^\s+Port\s+(?P<port>\d+):\s+(?P<status>\S+)\s+(?P<flags>.*?)"
|
||||
r"(?:\s+\[(?P<device_vid>[0-9a-fA-F]{4}):(?P<device_pid>[0-9a-fA-F]{4})(?:\s+(?P<device_desc>.+))?\])?\s*$"
|
||||
)
|
||||
|
||||
|
||||
class UhubctlError(RuntimeError):
|
||||
"""Raised on uhubctl-specific failures: parse errors, permission denied,
|
||||
hub-not-found, or PPPS not supported."""
|
||||
|
||||
|
||||
# ---------- Role → VID map -------------------------------------------------
|
||||
|
||||
# Mirrors the default hub_profile in `mcp-server/tests/conftest.py:335`.
|
||||
# Note: esp32s3 and esp32s3_alt share a logical role — we search both.
|
||||
ROLE_VIDS: dict[str, tuple[int, ...]] = {
|
||||
"nrf52": (0x239A,),
|
||||
"esp32s3": (0x303A, 0x10C4),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_role(role: str) -> str:
|
||||
"""Collapse `esp32s3_alt` → `esp32s3` to match the tier conventions."""
|
||||
return role.split("_alt", 1)[0].lower()
|
||||
|
||||
|
||||
# ---------- Core subprocess runner -----------------------------------------
|
||||
|
||||
|
||||
# If uhubctl hits a permission problem — most commonly Linux without the
|
||||
# udev rules, or a macOS variant where the kernel holds the hub driver —
|
||||
# it prints something like "Permission denied. Try running as root".
|
||||
# Linux error text varies; we match a broad substring rather than exact.
|
||||
_PERM_ERROR_PATTERNS = (
|
||||
"permission denied",
|
||||
"operation not permitted",
|
||||
"try running as root",
|
||||
"need root",
|
||||
"requires root",
|
||||
)
|
||||
|
||||
|
||||
def _run_uhubctl(args: Sequence[str], *, timeout: float = 30.0) -> dict[str, Any]:
|
||||
"""Invoke uhubctl with the given args. Returns `hw_tools._run`'s dict.
|
||||
|
||||
Translates permission-denied failures into a `UhubctlError` with the
|
||||
install hint, so callers don't have to match stderr themselves. Other
|
||||
non-zero exits are returned as-is for the caller to interpret.
|
||||
"""
|
||||
binary = config.uhubctl_bin()
|
||||
result = hw_tools._run(binary, args, timeout=timeout) # noqa: SLF001
|
||||
if result["exit_code"] != 0:
|
||||
combined = (result.get("stderr") or "") + "\n" + (result.get("stdout") or "")
|
||||
lower = combined.lower()
|
||||
if any(pat in lower for pat in _PERM_ERROR_PATTERNS):
|
||||
raise UhubctlError(
|
||||
"uhubctl exited with a permission error. Install the udev "
|
||||
"rules on Linux, or try `sudo` as a fallback: "
|
||||
"https://github.com/mvp/uhubctl#linux-usb-permissions\n"
|
||||
f"stderr: {result.get('stderr_tail')!r}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------- List / parse ---------------------------------------------------
|
||||
|
||||
|
||||
def parse_list_output(output: str) -> list[dict[str, Any]]:
|
||||
"""Parse the default `uhubctl` stdout into structured hubs.
|
||||
|
||||
Each hub: {
|
||||
"location": "1-1.3",
|
||||
"descriptor": "2109:2817 VIA Labs ...",
|
||||
"vid": 0x2109,
|
||||
"pid": 0x2817,
|
||||
"ppps": bool,
|
||||
"ports": [{"port": int, "status": str, "flags": str,
|
||||
"device_vid": int | None, "device_pid": int | None,
|
||||
"device_desc": str | None}, ...],
|
||||
}
|
||||
"""
|
||||
hubs: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
for line in output.splitlines():
|
||||
hm = _HUB_RE.match(line)
|
||||
if hm:
|
||||
descriptor = hm.group("descriptor")
|
||||
hub_vid, hub_pid = None, None
|
||||
vid_match = re.match(r"([0-9a-fA-F]{4}):([0-9a-fA-F]{4})", descriptor)
|
||||
if vid_match:
|
||||
hub_vid = int(vid_match.group(1), 16)
|
||||
hub_pid = int(vid_match.group(2), 16)
|
||||
current = {
|
||||
"location": hm.group("location"),
|
||||
"descriptor": descriptor,
|
||||
"vid": hub_vid,
|
||||
"pid": hub_pid,
|
||||
"ppps": ", ppps" in descriptor or descriptor.endswith("ppps"),
|
||||
"ports": [],
|
||||
}
|
||||
hubs.append(current)
|
||||
continue
|
||||
|
||||
pm = _PORT_RE.match(line)
|
||||
if pm and current is not None:
|
||||
device_vid = pm.group("device_vid")
|
||||
device_pid = pm.group("device_pid")
|
||||
current["ports"].append(
|
||||
{
|
||||
"port": int(pm.group("port")),
|
||||
"status": pm.group("status"),
|
||||
"flags": (pm.group("flags") or "").strip(),
|
||||
"device_vid": int(device_vid, 16) if device_vid else None,
|
||||
"device_pid": int(device_pid, 16) if device_pid else None,
|
||||
"device_desc": (pm.group("device_desc") or "").strip() or None,
|
||||
}
|
||||
)
|
||||
return hubs
|
||||
|
||||
|
||||
def list_hubs() -> list[dict[str, Any]]:
|
||||
"""Enumerate every hub uhubctl can see, with per-port device attachments.
|
||||
|
||||
Pure read — no power state changes. Useful as a pre-flight check before
|
||||
a destructive `power_off` call.
|
||||
"""
|
||||
result = _run_uhubctl([], timeout=15.0)
|
||||
if result["exit_code"] != 0:
|
||||
raise UhubctlError(
|
||||
f"uhubctl list failed (exit {result['exit_code']}): {result.get('stderr_tail')!r}"
|
||||
)
|
||||
return parse_list_output(result["stdout"])
|
||||
|
||||
|
||||
# ---------- Lookup / resolution -------------------------------------------
|
||||
|
||||
|
||||
def find_port_for_vid(
|
||||
vid: int, pid: int | None = None, *, only_ppps: bool = True
|
||||
) -> list[tuple[str, int]]:
|
||||
"""Return ALL (location, port) matches for a device VID (optionally +PID).
|
||||
|
||||
`only_ppps=True` filters out hubs that don't advertise PPPS — we can't
|
||||
control them anyway. Callers that want to diagnose a missing device can
|
||||
pass `only_ppps=False` to see if the device is on a non-controllable
|
||||
hub (and raise a clearer error).
|
||||
"""
|
||||
hubs = list_hubs()
|
||||
matches: list[tuple[str, int]] = []
|
||||
for hub in hubs:
|
||||
if only_ppps and not hub["ppps"]:
|
||||
continue
|
||||
for port in hub["ports"]:
|
||||
if port["device_vid"] != vid:
|
||||
continue
|
||||
if pid is not None and port["device_pid"] != pid:
|
||||
continue
|
||||
matches.append((hub["location"], port["port"]))
|
||||
return matches
|
||||
|
||||
|
||||
def resolve_target(role: str) -> tuple[str, int]:
|
||||
"""Resolve a Meshtastic role to (hub_location, port_number).
|
||||
|
||||
Priority:
|
||||
1. Env vars `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` + `_PORT_<ROLE>`
|
||||
(e.g. `MESHTASTIC_UHUBCTL_LOCATION_NRF52=1-1.3`, `_PORT_NRF52=2`).
|
||||
2. VID auto-detect against `ROLE_VIDS[role]`, taking the first PPPS
|
||||
match.
|
||||
|
||||
Raises `UhubctlError` on ambiguity (multiple matches) or no-match. The
|
||||
env-var path exists specifically to disambiguate when two devices share
|
||||
a VID.
|
||||
"""
|
||||
role = _normalize_role(role)
|
||||
env_key_loc = f"MESHTASTIC_UHUBCTL_LOCATION_{role.upper()}"
|
||||
env_key_port = f"MESHTASTIC_UHUBCTL_PORT_{role.upper()}"
|
||||
loc = os.environ.get(env_key_loc)
|
||||
port_str = os.environ.get(env_key_port)
|
||||
if loc and port_str:
|
||||
try:
|
||||
return (loc, int(port_str))
|
||||
except ValueError as exc:
|
||||
raise UhubctlError(
|
||||
f"{env_key_port}={port_str!r} is not a valid integer"
|
||||
) from exc
|
||||
|
||||
if role not in ROLE_VIDS:
|
||||
raise UhubctlError(
|
||||
f"unknown role {role!r}; known roles: {sorted(ROLE_VIDS)}. "
|
||||
f"Set {env_key_loc} + {env_key_port} to pin manually."
|
||||
)
|
||||
|
||||
matches: list[tuple[str, int]] = []
|
||||
for vid in ROLE_VIDS[role]:
|
||||
matches.extend(find_port_for_vid(vid))
|
||||
|
||||
if not matches:
|
||||
vids = ", ".join(f"0x{v:04x}" for v in ROLE_VIDS[role])
|
||||
raise UhubctlError(
|
||||
f"no controllable hub hosts a device with VID in {{{vids}}} "
|
||||
f"for role={role!r}. Check the device is plugged into a "
|
||||
f"PPPS-capable hub, or pin manually via {env_key_loc} + {env_key_port}."
|
||||
)
|
||||
if len(matches) > 1:
|
||||
shown = ", ".join(f"{loc}:port{p}" for loc, p in matches)
|
||||
raise UhubctlError(
|
||||
f"ambiguous: multiple devices match role={role!r} ({shown}). "
|
||||
f"Pin the target via {env_key_loc} + {env_key_port}."
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
# ---------- Power actions --------------------------------------------------
|
||||
|
||||
|
||||
def _action(
|
||||
action: str,
|
||||
location: str,
|
||||
port: int,
|
||||
*,
|
||||
delay_s: int | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> dict[str, Any]:
|
||||
args: list[str] = ["-a", action, "-l", location, "-p", str(port)]
|
||||
if delay_s is not None:
|
||||
args.extend(["-d", str(delay_s)])
|
||||
# Suppress verbose "before" printout so our parser doesn't have to skip it.
|
||||
args.append("-N")
|
||||
result = _run_uhubctl(args, timeout=timeout)
|
||||
if result["exit_code"] != 0:
|
||||
raise UhubctlError(
|
||||
f"uhubctl -a {action} -l {location} -p {port} failed "
|
||||
f"(exit {result['exit_code']}): {result.get('stderr_tail')!r}"
|
||||
)
|
||||
return {
|
||||
"action": action,
|
||||
"location": location,
|
||||
"port": port,
|
||||
"delay_s": delay_s,
|
||||
"duration_s": result["duration_s"],
|
||||
}
|
||||
|
||||
|
||||
def power_on(location: str, port: int) -> dict[str, Any]:
|
||||
"""Drive the port VBUS high. Device re-enumerates in 1-3 s on healthy hubs."""
|
||||
return _action("on", location, port)
|
||||
|
||||
|
||||
def power_off(location: str, port: int) -> dict[str, Any]:
|
||||
"""Drive the port VBUS low. Device disappears from `list_devices` immediately."""
|
||||
return _action("off", location, port)
|
||||
|
||||
|
||||
def cycle(location: str, port: int, delay_s: int = 2) -> dict[str, Any]:
|
||||
"""Off → wait `delay_s` → on. The common hard-reset pattern."""
|
||||
# uhubctl's own `-a cycle` handles the delay internally; we use a
|
||||
# slightly longer timeout to accommodate delay_s + enumeration.
|
||||
return _action("cycle", location, port, delay_s=delay_s, timeout=30.0 + delay_s * 2)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ROLE_VIDS",
|
||||
"UhubctlError",
|
||||
"cycle",
|
||||
"find_port_for_vid",
|
||||
"list_hubs",
|
||||
"parse_list_output",
|
||||
"power_off",
|
||||
"power_on",
|
||||
"resolve_target",
|
||||
]
|
||||
@@ -393,6 +393,7 @@ def build_testing_profile(
|
||||
long_name: str | None = None,
|
||||
disable_mqtt: bool = True,
|
||||
disable_position: bool = False,
|
||||
enable_ui_log: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a USERPREFS dict for an isolated test-mesh device.
|
||||
|
||||
@@ -423,6 +424,10 @@ def build_testing_profile(
|
||||
traffic never leaks to a public broker.
|
||||
disable_position: if True, disables GPS + position broadcasts — useful
|
||||
when test devices sit on a bench without antennas.
|
||||
enable_ui_log: if True, stamps `USERPREFS_UI_TEST_LOG=true` so the
|
||||
firmware emits one `Screen: frame N/M name=... reason=...` log
|
||||
line per frame transition. Test-only; off by default because the
|
||||
log is chatty (multiple times per second during UI interaction).
|
||||
|
||||
"""
|
||||
if region not in KNOWN_REGIONS:
|
||||
@@ -475,6 +480,9 @@ def build_testing_profile(
|
||||
prefs["USERPREFS_CONFIG_OWNER_LONG_NAME"] = long_name
|
||||
if short_name is not None:
|
||||
prefs["USERPREFS_CONFIG_OWNER_SHORT_NAME"] = short_name
|
||||
if enable_ui_log:
|
||||
# Consumed by `#ifdef USERPREFS_UI_TEST_LOG` in src/graphics/Screen.cpp.
|
||||
prefs["USERPREFS_UI_TEST_LOG"] = True
|
||||
|
||||
return prefs
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""USB hub power control for tests — thin composition of the `uhubctl`
|
||||
module + `_port_discovery.resolve_port_by_role`.
|
||||
|
||||
Why separate from the production module:
|
||||
- `meshtastic_mcp.uhubctl.cycle` returns as soon as uhubctl exits (VBUS is
|
||||
back on, but the device hasn't finished enumerating as a CDC port yet).
|
||||
- Tests that want to immediately issue a `connect(port=...)` need the NEW
|
||||
`/dev/cu.*` path, which can differ from the pre-cycle path on nRF52
|
||||
boards (CDC re-enumeration assigns a fresh `cu.usbmodemNNNN`).
|
||||
- `resolve_port_by_role` already handles that wait + path-resolution for
|
||||
the `factory_reset` flow. Composing the two gives a one-call helper.
|
||||
|
||||
Also exposes `is_uhubctl_available()` so fixtures can skip cleanly when
|
||||
uhubctl isn't installed — we never want "no uhubctl" to look like a test
|
||||
failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from meshtastic_mcp import config as config_mod
|
||||
from meshtastic_mcp import uhubctl as uhubctl_mod
|
||||
|
||||
from ._port_discovery import resolve_port_by_role
|
||||
|
||||
|
||||
def is_uhubctl_available() -> bool:
|
||||
"""Return True iff `config.uhubctl_bin()` resolves AND the binary is callable.
|
||||
|
||||
Soft-fails silently — fixtures use this to `pytest.skip` with an
|
||||
actionable message when the operator hasn't installed uhubctl.
|
||||
"""
|
||||
try:
|
||||
config_mod.uhubctl_bin()
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
# Do NOT actually invoke uhubctl here — on macOS a non-sudo run would
|
||||
# fail, which is a config issue, not a tool-missing issue. That gets
|
||||
# surfaced to the user when they actually run a recovery action.
|
||||
return True
|
||||
|
||||
|
||||
def power_on(role: str) -> dict[str, Any]:
|
||||
"""Power on the hub port hosting `role`. Does NOT wait for re-enumeration.
|
||||
Use `power_cycle` or follow with `resolve_port_by_role` to block on readiness.
|
||||
"""
|
||||
loc, port = uhubctl_mod.resolve_target(role)
|
||||
return uhubctl_mod.power_on(loc, port)
|
||||
|
||||
|
||||
def power_off(role: str) -> dict[str, Any]:
|
||||
"""Power off the hub port hosting `role`. The device disappears from
|
||||
`list_devices` immediately.
|
||||
"""
|
||||
loc, port = uhubctl_mod.resolve_target(role)
|
||||
return uhubctl_mod.power_off(loc, port)
|
||||
|
||||
|
||||
def power_cycle(
|
||||
role: str,
|
||||
*,
|
||||
delay_s: int = 2,
|
||||
rediscover_timeout_s: float = 30.0,
|
||||
) -> str:
|
||||
"""Cycle the port hosting `role`, wait for re-enumeration, return the
|
||||
new port path.
|
||||
|
||||
On nRF52 the post-cycle path typically matches the pre-cycle path, but
|
||||
macOS may assign a different `/dev/cu.usbmodemNNNN` if the previous
|
||||
CDC endpoint hasn't been fully released. `resolve_port_by_role`
|
||||
handles that transparently.
|
||||
"""
|
||||
loc, port = uhubctl_mod.resolve_target(role)
|
||||
uhubctl_mod.cycle(loc, port, delay_s=delay_s)
|
||||
# After uhubctl exits, VBUS is on but the device may still be in
|
||||
# bootloader init. Give it ~500 ms head-start before polling so we
|
||||
# don't spam list_devices pointlessly.
|
||||
time.sleep(0.5)
|
||||
return resolve_port_by_role(role, timeout_s=rediscover_timeout_s)
|
||||
|
||||
|
||||
def wait_for_absence(role: str, *, timeout_s: float = 10.0) -> None:
|
||||
"""Block until a device matching `role` is NOT in `list_devices`.
|
||||
|
||||
Used by the recovery tier to assert power_off actually took effect.
|
||||
Raises TimeoutError on failure.
|
||||
"""
|
||||
from meshtastic_mcp import devices as devices_mod
|
||||
|
||||
from ._port_discovery import _ROLE_VIDS, _coerce_vid # type: ignore[attr-defined]
|
||||
|
||||
if role not in _ROLE_VIDS:
|
||||
raise ValueError(f"unknown role {role!r}")
|
||||
wanted = _ROLE_VIDS[role]
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
found = devices_mod.list_devices(include_unknown=True)
|
||||
if not any(_coerce_vid(d.get("vid")) in wanted for d in found):
|
||||
return
|
||||
time.sleep(0.3)
|
||||
raise TimeoutError(f"role {role!r} still visible after {timeout_s}s of power_off")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"is_uhubctl_available",
|
||||
"power_cycle",
|
||||
"power_off",
|
||||
"power_on",
|
||||
"wait_for_absence",
|
||||
]
|
||||
@@ -123,15 +123,24 @@ def pytest_collection_modifyitems(
|
||||
return (2, item.nodeid)
|
||||
if "/monitor/" in path or "tests/monitor" in path:
|
||||
return (3, item.nodeid)
|
||||
if "/fleet/" in path or "tests/fleet" in path:
|
||||
# Recovery tier: explicitly cycles device power via uhubctl. Slots
|
||||
# between monitor (read-only) and ui (state-preserving) so any tier
|
||||
# after it starts from a known re-enumerated + re-verified state.
|
||||
if "/recovery/" in path or "tests/recovery" in path:
|
||||
return (4, item.nodeid)
|
||||
# UI tier slots here — read-only w.r.t. mesh state, only mutates
|
||||
# the on-screen UI (BACK×5 guard restores home before each test).
|
||||
if "/ui/" in path or "tests/ui" in path:
|
||||
return (5, item.nodeid)
|
||||
if "/fleet/" in path or "tests/fleet" in path:
|
||||
return (6, item.nodeid)
|
||||
# State-mutating tiers run last.
|
||||
if "/admin/" in path or "tests/admin" in path:
|
||||
return (5, item.nodeid)
|
||||
return (7, item.nodeid)
|
||||
if "/provisioning/" in path or "tests/provisioning" in path:
|
||||
return (6, item.nodeid)
|
||||
return (8, item.nodeid)
|
||||
# Top-level + anything else falls between.
|
||||
return (7, item.nodeid)
|
||||
return (9, item.nodeid)
|
||||
|
||||
items.sort(key=sort_key)
|
||||
|
||||
@@ -156,13 +165,20 @@ def session_seed(request: pytest.FixtureRequest) -> str:
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_profile(session_seed: str) -> dict[str, Any]:
|
||||
"""The canonical isolated-mesh test profile for this session."""
|
||||
"""The canonical isolated-mesh test profile for this session.
|
||||
|
||||
`enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware
|
||||
emits `Screen: frame N/M name=... reason=...` log lines per UI
|
||||
transition — consumed by the `tests/ui/` tier. Harmless on boards
|
||||
without a screen (the `#ifdef` sits behind `HAS_SCREEN`).
|
||||
"""
|
||||
return userprefs.build_testing_profile(
|
||||
psk_seed=session_seed,
|
||||
channel_name="McpTest",
|
||||
channel_num=88,
|
||||
region="US",
|
||||
modem_preset="LONG_FAST",
|
||||
enable_ui_log=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -654,6 +670,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||
def baked_single(
|
||||
baked_mesh: dict[str, Any],
|
||||
baked_single_role: str,
|
||||
hub_devices: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Function-scoped: a single verified baked device.
|
||||
|
||||
@@ -662,10 +679,75 @@ def baked_single(
|
||||
(e.g. `test_owner_survives_reboot[nrf52]` +
|
||||
`test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role
|
||||
and never skip a device that happens to be connected.
|
||||
|
||||
Auto-recovery: if the baked device fails a pre-test `device_info` probe
|
||||
AND uhubctl is available, power-cycle the port once and retry. Without
|
||||
uhubctl, surface the wedge as a clear skip. This catches "device got
|
||||
stuck between tests" without masking persistent regressions (a second
|
||||
wedge after cycling still skips).
|
||||
"""
|
||||
if baked_single_role not in baked_mesh:
|
||||
pytest.skip(f"role {baked_single_role!r} not present on the hub")
|
||||
return {"role": baked_single_role, **baked_mesh[baked_single_role]}
|
||||
|
||||
entry = baked_mesh[baked_single_role]
|
||||
port = entry.get("port")
|
||||
if port:
|
||||
try:
|
||||
_run_with_timeout(lambda: info.device_info(port=port, timeout_s=3.0), 5.0)
|
||||
except Exception:
|
||||
# Device didn't respond. Try a power-cycle recovery if uhubctl
|
||||
# is installed; otherwise surface a skip that names the root
|
||||
# cause clearly.
|
||||
from tests import _power
|
||||
|
||||
if not _power.is_uhubctl_available():
|
||||
pytest.skip(
|
||||
f"device {baked_single_role!r} unresponsive on {port}; "
|
||||
"install uhubctl (`brew install uhubctl` / `apt install "
|
||||
"uhubctl`) for auto power-cycle recovery"
|
||||
)
|
||||
try:
|
||||
new_port = _power.power_cycle(baked_single_role, delay_s=2)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
pytest.skip(
|
||||
f"device {baked_single_role!r} wedged and power-cycle "
|
||||
f"failed: {exc}"
|
||||
)
|
||||
# Mutate both the session-scoped `hub_devices` map AND the
|
||||
# baked_mesh entry so downstream fixtures see the recovered port.
|
||||
hub_devices[baked_single_role] = new_port
|
||||
baked_mesh[baked_single_role]["port"] = new_port
|
||||
entry = baked_mesh[baked_single_role]
|
||||
return {"role": baked_single_role, **entry}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def power_cycle(
|
||||
hub_devices: dict[str, str],
|
||||
) -> Callable[..., str]:
|
||||
"""Return a callable `(role, delay_s=2) -> new_port` that hard-resets the
|
||||
hub port hosting `role`. Skips the test cleanly when uhubctl isn't
|
||||
installed — never want "no uhubctl" to look like a test failure.
|
||||
|
||||
The callable mutates `hub_devices[role]` in place so subsequent fixture
|
||||
lookups pick up the post-cycle port (mirrors the pattern in
|
||||
provisioning/test_userprefs_survive_factory_reset.py).
|
||||
"""
|
||||
from tests import _power
|
||||
|
||||
if not _power.is_uhubctl_available():
|
||||
pytest.skip(
|
||||
"uhubctl not installed; this test needs it for power control. "
|
||||
"Install via `brew install uhubctl` (macOS) or `apt install "
|
||||
"uhubctl` (Debian/Ubuntu)."
|
||||
)
|
||||
|
||||
def _cycle(role: str, delay_s: int = 2) -> str:
|
||||
new_port = _power.power_cycle(role, delay_s=delay_s)
|
||||
hub_devices[role] = new_port
|
||||
return new_port
|
||||
|
||||
return _cycle
|
||||
|
||||
|
||||
_DEFAULT_ROLE_ENVS = {
|
||||
@@ -960,6 +1042,45 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any:
|
||||
raise TimeoutError(f"operation did not complete within {timeout}s") from exc
|
||||
|
||||
|
||||
def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
|
||||
"""Embed per-step UI captures (PNG + OCR) into the pytest-html extras.
|
||||
|
||||
Runs for every UI-tier test on BOTH pass and fail so the HTML report
|
||||
always shows the image strip + OCR transcript. Silently no-ops if
|
||||
pytest-html isn't installed or the test didn't use `frame_capture`.
|
||||
"""
|
||||
captures = getattr(item, "_ui_captures", None)
|
||||
if not captures:
|
||||
return
|
||||
try:
|
||||
from pytest_html import extras as html_extras # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
existing = getattr(report, "extras", None) or []
|
||||
extras_list = list(existing)
|
||||
for cap in captures:
|
||||
png_path = cap.get("png_path")
|
||||
label = f"{cap.get('step', '?')}: {cap.get('label', '')}"
|
||||
frame = cap.get("frame") or {}
|
||||
frame_str = (
|
||||
f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
|
||||
)
|
||||
if png_path:
|
||||
try:
|
||||
with open(png_path, "rb") as fh:
|
||||
import base64
|
||||
|
||||
b64 = base64.b64encode(fh.read()).decode("ascii")
|
||||
extras_list.append(html_extras.png(b64, name=f"{label}{frame_str}"))
|
||||
except OSError:
|
||||
pass
|
||||
ocr = (cap.get("ocr_text") or "").strip()
|
||||
if ocr:
|
||||
extras_list.append(html_extras.text(ocr, name=f"OCR: {label}{frame_str}"))
|
||||
report.extras = extras_list # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any:
|
||||
"""On test failure, attach serial capture + device state as report artifacts.
|
||||
@@ -967,10 +1088,20 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
|
||||
Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck
|
||||
port, unbaked firmware, dead board), the dump is skipped rather than
|
||||
hanging the session.
|
||||
|
||||
For UI-tier tests, also embeds per-step camera captures + OCR on every
|
||||
test (pass or fail) so the HTML report shows visual evidence of what
|
||||
the device did.
|
||||
"""
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
|
||||
# Attach UI captures on any outcome (pass + fail) — these are the whole
|
||||
# point of the UI tier. Do this before the failure-only branch below so
|
||||
# passing tests still get their image strip.
|
||||
if report.when == "call":
|
||||
_attach_ui_captures(item, report)
|
||||
|
||||
if report.when != "call" or report.outcome != "failed":
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Isolation test for peer-offline-then-back mid-conversation.
|
||||
|
||||
Verifies the mesh stack's behavior when a peer is physically powered
|
||||
off mid-send via uhubctl, then powered back on.
|
||||
|
||||
Flow (parametrized over every directed mesh_pair):
|
||||
1. Bilateral PKI warmup (same pattern as test_direct_with_ack).
|
||||
2. TX sends a broadcast text "msg-1" — RX confirms receipt via pubsub.
|
||||
3. Power OFF RX via uhubctl. The RX device disappears from the OS.
|
||||
4. TX sends a directed text "msg-2" with wantAck=True. Firmware retries
|
||||
internally for ~30s before giving up. Assertion: the packet object
|
||||
was accepted by the TX stack (non-None) — we don't assert an ACK
|
||||
since there's no peer to send one.
|
||||
5. Power ON RX. Wait for re-enumeration + boot.
|
||||
6. Bilateral PKI re-nudge — RX's in-RAM PKI cache was wiped on reboot,
|
||||
so the first directed send may err=35 without a fresh NodeInfo ping.
|
||||
7. TX sends a directed "msg-3" — RX receives it via pubsub, confirming
|
||||
the mesh recovered.
|
||||
|
||||
Skips cleanly if uhubctl isn't installed (via the `power_cycle` fixture's
|
||||
auto-skip). Skips for pair directions where RX isn't power-controllable
|
||||
(e.g. a USB-IF hub that doesn't support PPPS for its port).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.connection import connect
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
from ._receive import ReceiveCollector, nudge_nodeinfo
|
||||
|
||||
|
||||
@pytest.mark.timeout(360)
|
||||
def test_peer_offline_then_recovers(
|
||||
mesh_pair: dict[str, Any],
|
||||
power_cycle, # noqa: ARG001 — forces uhubctl-availability skip
|
||||
hub_devices: dict[str, str],
|
||||
) -> None:
|
||||
tx_port = mesh_pair["tx"]["port"]
|
||||
rx_node_num = mesh_pair["rx"]["my_node_num"]
|
||||
tx_role = mesh_pair["tx_role"]
|
||||
rx_role = mesh_pair["rx_role"]
|
||||
|
||||
unique_pre = f"peer-offline-pre-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
unique_post = f"peer-offline-post-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
# Step 1 + 2: warm up + confirm baseline delivery works before the test.
|
||||
with ReceiveCollector(
|
||||
mesh_pair["rx"]["port"], topic="meshtastic.receive.text"
|
||||
) as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
# Wait for bilateral PKI (RX pubkey in TX's nodesByNum).
|
||||
deadline = time.monotonic() + 45.0
|
||||
last_nudge = time.monotonic()
|
||||
while time.monotonic() < deadline:
|
||||
rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {})
|
||||
if rec.get("user", {}).get("publicKey"):
|
||||
break
|
||||
if time.monotonic() - last_nudge > 15.0:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
last_nudge = time.monotonic()
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
pytest.skip(
|
||||
f"bilateral PKI never completed ({tx_role}→{rx_role}); "
|
||||
"can't run the offline test without a warm baseline"
|
||||
)
|
||||
|
||||
tx_iface.sendText(unique_pre, destinationId=rx_node_num, wantAck=True)
|
||||
got = rx.wait_for(
|
||||
lambda pkt: pkt.get("decoded", {}).get("text") == unique_pre,
|
||||
timeout=30,
|
||||
)
|
||||
assert got is not None, (
|
||||
f"baseline directed send ({tx_role}→{rx_role}) didn't land — "
|
||||
"skipping offline test to avoid false positive"
|
||||
)
|
||||
|
||||
# Step 3: power off RX. uhubctl skips the test with a clear message if
|
||||
# the RX role isn't on a controllable hub.
|
||||
try:
|
||||
_power.power_off(rx_role)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
pytest.skip(f"can't power-control {rx_role!r}: {exc}")
|
||||
|
||||
try:
|
||||
_power.wait_for_absence(rx_role, timeout_s=10.0)
|
||||
except TimeoutError:
|
||||
_power.power_on(rx_role) # restore hub state before failing
|
||||
resolve_port_by_role(rx_role, timeout_s=30.0)
|
||||
pytest.fail(f"{rx_role!r} didn't disappear after power_off")
|
||||
|
||||
# Step 4: send to a peer that isn't there. Firmware will retry
|
||||
# internally. We don't wait for an ACK (there won't be one); we just
|
||||
# confirm TX's stack accepts the packet without crashing.
|
||||
try:
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
packet = tx_iface.sendText(
|
||||
f"while-offline-{rx_role}",
|
||||
destinationId=rx_node_num,
|
||||
wantAck=True,
|
||||
)
|
||||
assert packet is not None
|
||||
# Give firmware a moment to do a retry or two while RX is down.
|
||||
time.sleep(5.0)
|
||||
except Exception as exc: # noqa: BLE001 — TX should survive the peer being gone
|
||||
# Restore RX before reraising so the bench state is sane.
|
||||
_power.power_on(rx_role)
|
||||
resolve_port_by_role(rx_role, timeout_s=30.0)
|
||||
raise AssertionError(f"TX crashed when sending to offline peer: {exc}") from exc
|
||||
|
||||
# Step 5: power RX back on + rediscover.
|
||||
_power.power_on(rx_role)
|
||||
time.sleep(0.5)
|
||||
new_rx_port = resolve_port_by_role(rx_role, timeout_s=30.0)
|
||||
hub_devices[rx_role] = new_rx_port
|
||||
|
||||
# Step 6 + 7: bilateral re-warmup + directed send that should now work.
|
||||
with ReceiveCollector(new_rx_port, topic="meshtastic.receive.text") as rx:
|
||||
# RX rebooted → its PKI cache is gone. Re-warm.
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(3.0)
|
||||
|
||||
got = None
|
||||
for _attempt in range(3):
|
||||
packet = tx_iface.sendText(
|
||||
unique_post,
|
||||
destinationId=rx_node_num,
|
||||
wantAck=True,
|
||||
)
|
||||
assert packet is not None
|
||||
got = rx.wait_for(
|
||||
lambda pkt: pkt.get("decoded", {}).get("text") == unique_post,
|
||||
timeout=30,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(5.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"post-recovery directed send {unique_post!r} ({tx_role}→{rx_role}) "
|
||||
"never landed — recovery path may be broken"
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Recovery tier — exercises `uhubctl` power control end-to-end.
|
||||
|
||||
Requires `uhubctl` installed AND at least one connected device on a
|
||||
PPPS-capable hub. The whole tier skips cleanly via
|
||||
`tests/recovery/conftest.py::_recovery_tier_guard` when either is missing.
|
||||
"""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Recovery-tier gating + shared helpers.
|
||||
|
||||
Session-scoped guard skips the whole tier when uhubctl isn't installed.
|
||||
Tests under this directory assume uhubctl is callable AND that at least
|
||||
one hub role is detected on a PPPS-capable port.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _recovery_tier_guard() -> None:
|
||||
"""Skip the tier when uhubctl is unavailable OR no device is on a
|
||||
PPPS-capable hub. Prints the specific reason so operators know what
|
||||
to fix."""
|
||||
from tests import _power
|
||||
|
||||
if not _power.is_uhubctl_available():
|
||||
pytest.skip(
|
||||
"uhubctl not installed; recovery tier needs it. "
|
||||
"Install via `brew install uhubctl` or `apt install uhubctl`.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
# Probe: can we even list hubs? (A macOS user without sudo gets a
|
||||
# permission error here — we'd rather find out once at tier-start than
|
||||
# 6 tests later.)
|
||||
from meshtastic_mcp import uhubctl
|
||||
|
||||
try:
|
||||
hubs = uhubctl.list_hubs()
|
||||
except uhubctl.UhubctlError as exc:
|
||||
pytest.skip(
|
||||
f"uhubctl list failed: {exc}. Try the udev rules or `sudo` as a fallback.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
if not any(h["ppps"] for h in hubs):
|
||||
pytest.skip(
|
||||
"no PPPS-capable hubs detected — recovery tier has nothing to exercise.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Smoke test: `uhubctl_list` returns a well-formed structure.
|
||||
|
||||
No destructive action. Runs first in the tier as a sanity check that the
|
||||
tier's dependencies (uhubctl binary + permissions) are actually satisfied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import uhubctl
|
||||
|
||||
|
||||
@pytest.mark.timeout(30)
|
||||
def test_list_hubs_returns_at_least_one_ppps_hub() -> None:
|
||||
hubs = uhubctl.list_hubs()
|
||||
assert hubs, "uhubctl found no hubs at all — is a USB hub connected?"
|
||||
assert any(h["ppps"] for h in hubs), (
|
||||
"no PPPS-capable hubs detected; power control won't work. "
|
||||
"Check that the hub supports Per-Port Power Switching."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30)
|
||||
def test_list_hubs_structure(hub_devices: dict[str, str]) -> None:
|
||||
hubs = uhubctl.list_hubs()
|
||||
for hub in hubs:
|
||||
assert "location" in hub and hub["location"]
|
||||
assert "ports" in hub and isinstance(hub["ports"], list)
|
||||
for port in hub["ports"]:
|
||||
assert "port" in port and isinstance(port["port"], int)
|
||||
assert "status" in port
|
||||
|
||||
# At least one of the detected Meshtastic roles should show up in some
|
||||
# port's device_vid — otherwise the recovery tier can't drive them.
|
||||
seen_vids = {
|
||||
p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None
|
||||
}
|
||||
expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids
|
||||
assert expected_any or not hub_devices, (
|
||||
f"hub_devices detected roles {list(hub_devices)} but uhubctl sees "
|
||||
f"VIDs {sorted(hex(v) for v in seen_vids)} — the devices may be on "
|
||||
"a hub that uhubctl can't see (e.g. built-in laptop ports)."
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Hard reset via uhubctl must NOT wipe NVS. Verify the test profile's
|
||||
region + channel survive a power-cycle.
|
||||
|
||||
Guards against a regression where a firmware change treats unexpected
|
||||
power loss as a factory-reset trigger (e.g. bad EEPROM wear-leveling,
|
||||
erase-on-boot-for-safety). Such a regression would be catastrophic for
|
||||
field deployments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import admin, info
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_lora_config_survives_power_cycle(
|
||||
baked_single: dict[str, object],
|
||||
test_profile: dict[str, object],
|
||||
) -> None:
|
||||
role = baked_single["role"]
|
||||
pre_port = baked_single["port"]
|
||||
|
||||
pre_config = admin.get_config(section="lora", port=pre_port)["config"]["lora"]
|
||||
pre_region = pre_config.get("region")
|
||||
pre_preset = pre_config.get("modem_preset")
|
||||
assert pre_region, f"lora.region not set pre-cycle on {role}"
|
||||
|
||||
# Hard power-cycle.
|
||||
_power.power_cycle(role, delay_s=2)
|
||||
time.sleep(0.5)
|
||||
new_port = resolve_port_by_role(role, timeout_s=30.0)
|
||||
# Let the firmware complete boot before admin reads.
|
||||
time.sleep(2.0)
|
||||
# Quick readiness probe.
|
||||
probe = info.device_info(port=new_port, timeout_s=10.0)
|
||||
assert (
|
||||
probe.get("my_node_num") is not None
|
||||
), f"device {role!r} didn't respond after power-cycle"
|
||||
|
||||
post_config = admin.get_config(section="lora", port=new_port)["config"]["lora"]
|
||||
post_region = post_config.get("region")
|
||||
post_preset = post_config.get("modem_preset")
|
||||
|
||||
assert post_region == pre_region, (
|
||||
f"lora.region wiped by power-cycle on {role}: "
|
||||
f"pre={pre_region!r} post={post_region!r}"
|
||||
)
|
||||
assert post_preset == pre_preset, (
|
||||
f"lora.modem_preset wiped by power-cycle on {role}: "
|
||||
f"pre={pre_preset!r} post={post_preset!r}"
|
||||
)
|
||||
|
||||
# Channel-0 name should also match the test profile.
|
||||
pri_ch = admin.get_channel_url(port=new_port)
|
||||
assert pri_ch.get("url"), f"channel URL empty after power-cycle on {role}"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Full power-cycle round-trip: off → verify gone → on → verify identity
|
||||
preserved.
|
||||
|
||||
Parametrized over every connected role. Validates both the uhubctl
|
||||
plumbing AND that the device survives a hard reset with the same
|
||||
`my_node_num` (no firmware-level identity regeneration).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import info
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_power_cycle_preserves_node_identity(
|
||||
baked_single: dict[str, object],
|
||||
) -> None:
|
||||
role = baked_single["role"]
|
||||
pre_port = baked_single["port"]
|
||||
pre_node_num = baked_single["my_node_num"]
|
||||
pre_fw = baked_single.get("firmware_version")
|
||||
|
||||
# Record pre-cycle state.
|
||||
pre_info = info.device_info(port=pre_port, timeout_s=5.0)
|
||||
assert pre_info.get("my_node_num") == pre_node_num
|
||||
|
||||
# Power off; confirm the device actually disappears from list_devices.
|
||||
_power.power_off(role)
|
||||
try:
|
||||
_power.wait_for_absence(role, timeout_s=10.0)
|
||||
except TimeoutError:
|
||||
# If it didn't disappear, power it back on so we don't leave the
|
||||
# hub in a weird state for the next test.
|
||||
_power.power_on(role)
|
||||
resolve_port_by_role(role, timeout_s=30.0)
|
||||
pytest.fail(f"device {role!r} stayed visible after power_off")
|
||||
|
||||
# Power back on + re-discover port.
|
||||
_power.power_on(role)
|
||||
time.sleep(0.5) # head-start before polling
|
||||
new_port = resolve_port_by_role(role, timeout_s=30.0)
|
||||
|
||||
# Give the firmware a moment to finish boot before we hit it with admin.
|
||||
time.sleep(2.0)
|
||||
|
||||
post_info = info.device_info(port=new_port, timeout_s=10.0)
|
||||
assert post_info.get("my_node_num") == pre_node_num, (
|
||||
f"my_node_num changed across power-cycle: pre={pre_node_num:#x} "
|
||||
f"post={post_info.get('my_node_num'):#x}"
|
||||
)
|
||||
# Firmware version must match (same bake, not a re-flash).
|
||||
if pre_fw:
|
||||
assert post_info.get("firmware_version") == pre_fw, (
|
||||
f"firmware changed across cycle: pre={pre_fw} "
|
||||
f"post={post_info.get('firmware_version')}"
|
||||
)
|
||||
@@ -73,6 +73,13 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
|
||||
"reboot": ("meshtastic_mcp.admin", "reboot"),
|
||||
"shutdown": ("meshtastic_mcp.admin", "shutdown"),
|
||||
"factory_reset": ("meshtastic_mcp.admin", "factory_reset"),
|
||||
"send_input_event": ("meshtastic_mcp.admin", "send_input_event"),
|
||||
# `capture_screen` in server.py calls camera.get_camera — instrument that.
|
||||
"capture_screen": ("meshtastic_mcp.camera", "get_camera"),
|
||||
# USB power control via uhubctl.
|
||||
"uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"),
|
||||
"uhubctl_power": ("meshtastic_mcp.uhubctl", "power_on"),
|
||||
"uhubctl_cycle": ("meshtastic_mcp.uhubctl", "cycle"),
|
||||
# USERPREFS
|
||||
"userprefs_manifest": ("meshtastic_mcp.userprefs", "build_manifest"),
|
||||
"userprefs_get": ("meshtastic_mcp.userprefs", "read_state"),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""UI tier — input-broker-driven screen navigation tests.
|
||||
|
||||
Only runs when a screen-bearing role (esp32s3/heltec-v3) is present on the
|
||||
hub AND the firmware was baked with `enable_ui_log=True` (so the
|
||||
`Screen: frame N/M name=... reason=...` log lines are emitted). The
|
||||
`tests/ui/conftest.py` fixture forces that bake stamp.
|
||||
"""
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Parse `Screen: frame N/M name=X reason=Y` log lines from `_debug_log_buffer`.
|
||||
|
||||
The firmware emits one line per frame transition when
|
||||
`USERPREFS_UI_TEST_LOG` is defined (see src/graphics/Screen.cpp). Tests use
|
||||
these helpers to assert which frame is shown / to wait for a transition to
|
||||
settle before taking a camera capture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
FRAME_RE = re.compile(
|
||||
r"Screen: frame (?P<idx>\d+)/(?P<count>\d+) name=(?P<name>\S+) reason=(?P<reason>\S+)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameEvent:
|
||||
idx: int
|
||||
count: int
|
||||
name: str
|
||||
reason: str
|
||||
raw: str
|
||||
|
||||
@classmethod
|
||||
def parse(cls, line: str) -> "FrameEvent | None":
|
||||
m = FRAME_RE.search(line)
|
||||
if not m:
|
||||
return None
|
||||
return cls(
|
||||
idx=int(m["idx"]),
|
||||
count=int(m["count"]),
|
||||
name=m["name"],
|
||||
reason=m["reason"],
|
||||
raw=line,
|
||||
)
|
||||
|
||||
|
||||
def iter_frame_events(lines: Iterable[str]) -> Iterator[FrameEvent]:
|
||||
for line in lines:
|
||||
evt = FrameEvent.parse(line)
|
||||
if evt is not None:
|
||||
yield evt
|
||||
|
||||
|
||||
def get_current_frame(lines: list[str]) -> FrameEvent | None:
|
||||
"""Return the most recent FrameEvent in `lines`, or None if none found."""
|
||||
for line in reversed(lines):
|
||||
evt = FrameEvent.parse(line)
|
||||
if evt is not None:
|
||||
return evt
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_frame(
|
||||
lines: list[str],
|
||||
expected_name: str,
|
||||
*,
|
||||
timeout_s: float = 5.0,
|
||||
poll_interval_s: float = 0.1,
|
||||
reason: str | None = None,
|
||||
) -> FrameEvent:
|
||||
"""Poll `lines` (the `_debug_log_buffer`) until a FrameEvent with
|
||||
`name=expected_name` appears after the call started. Raises TimeoutError
|
||||
with context if it doesn't arrive in `timeout_s`.
|
||||
|
||||
`reason` optionally filters to events matching a specific cause
|
||||
(e.g. `"fn_f1"`, `"next"`, `"rebuild"`).
|
||||
"""
|
||||
start_idx = len(lines)
|
||||
deadline = time.monotonic() + timeout_s
|
||||
last: FrameEvent | None = None
|
||||
while time.monotonic() < deadline:
|
||||
# Scan only lines appended since we started waiting.
|
||||
for line in lines[start_idx:]:
|
||||
evt = FrameEvent.parse(line)
|
||||
if evt is None:
|
||||
continue
|
||||
last = evt
|
||||
if evt.name == expected_name and (reason is None or evt.reason == reason):
|
||||
return evt
|
||||
time.sleep(poll_interval_s)
|
||||
|
||||
seen = [e.name for e in iter_frame_events(lines[start_idx:])]
|
||||
raise TimeoutError(
|
||||
f"frame name={expected_name!r} reason={reason!r} not seen in {timeout_s}s; "
|
||||
f"saw {len(seen)} transition(s): {seen!r}; last={last!r}"
|
||||
)
|
||||
|
||||
|
||||
def wait_for_any_frame(
|
||||
lines: list[str],
|
||||
*,
|
||||
timeout_s: float = 5.0,
|
||||
poll_interval_s: float = 0.1,
|
||||
) -> FrameEvent:
|
||||
"""Wait for ANY frame transition to appear after call-start. Useful for
|
||||
`no-op` tests that want to confirm a transition did NOT happen (via
|
||||
TimeoutError) vs. one that did.
|
||||
"""
|
||||
start_idx = len(lines)
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
for line in lines[start_idx:]:
|
||||
evt = FrameEvent.parse(line)
|
||||
if evt is not None:
|
||||
return evt
|
||||
time.sleep(poll_interval_s)
|
||||
raise TimeoutError(f"no frame transition in {timeout_s}s")
|
||||
|
||||
|
||||
def wait_for_reason(
|
||||
lines: list[str],
|
||||
reason: str,
|
||||
*,
|
||||
timeout_s: float = 5.0,
|
||||
poll_interval_s: float = 0.1,
|
||||
) -> FrameEvent:
|
||||
"""Wait for a frame event with `reason=<reason>` after call-start.
|
||||
|
||||
Matches only on `reason` — useful when the caller knows *why* a
|
||||
transition should happen (e.g. `fn_f1`, `rebuild`) but not which named
|
||||
frame the firmware will land on for this particular board.
|
||||
"""
|
||||
start_idx = len(lines)
|
||||
deadline = time.monotonic() + timeout_s
|
||||
last: FrameEvent | None = None
|
||||
while time.monotonic() < deadline:
|
||||
for line in lines[start_idx:]:
|
||||
evt = FrameEvent.parse(line)
|
||||
if evt is None:
|
||||
continue
|
||||
last = evt
|
||||
if evt.reason == reason:
|
||||
return evt
|
||||
time.sleep(poll_interval_s)
|
||||
raise TimeoutError(
|
||||
f"no frame with reason={reason!r} in {timeout_s}s; last={last!r}"
|
||||
)
|
||||
|
||||
|
||||
def assert_no_frame_change(
|
||||
lines: list[str],
|
||||
*,
|
||||
wait_s: float = 2.0,
|
||||
) -> None:
|
||||
"""Assert that NO new FrameEvent lines arrive within `wait_s`.
|
||||
|
||||
Used by idempotency / no-op tests (e.g. BACK on home frame).
|
||||
"""
|
||||
start_idx = len(lines)
|
||||
time.sleep(wait_s)
|
||||
new = [
|
||||
e for e in (FrameEvent.parse(ln) for ln in lines[start_idx:]) if e is not None
|
||||
]
|
||||
if new:
|
||||
raise AssertionError(
|
||||
f"expected no frame change in {wait_s}s, but saw {len(new)} event(s): "
|
||||
f"{[(e.reason, e.name) for e in new]!r}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FRAME_RE",
|
||||
"FrameEvent",
|
||||
"assert_no_frame_change",
|
||||
"get_current_frame",
|
||||
"iter_frame_events",
|
||||
"wait_for_any_frame",
|
||||
"wait_for_frame",
|
||||
"wait_for_reason",
|
||||
]
|
||||
@@ -0,0 +1,381 @@
|
||||
"""UI-tier fixtures: camera lifecycle, OCR warmup, per-test frame capture,
|
||||
and a `ui_home_state` autouse guard that resets to the home frame before
|
||||
every test (prevents state bleed if a prior test exited inside a menu).
|
||||
|
||||
The camera + OCR modules live in `meshtastic_mcp/{camera,ocr}.py` (production
|
||||
code, so the `capture_screen` MCP tool can share them). These fixtures wire
|
||||
them into pytest + write per-test captures to `tests/ui_captures/…`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import admin as admin_mod
|
||||
from meshtastic_mcp import camera as camera_mod
|
||||
from meshtastic_mcp import ocr as ocr_mod
|
||||
from meshtastic_mcp.input_events import InputEventCode
|
||||
|
||||
from ._screen_log import FrameEvent, get_current_frame, wait_for_frame
|
||||
|
||||
# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3
|
||||
# SSD1306) qualifies today — nrf52 (rak4631) has no display.
|
||||
UI_CAPABLE_ROLES = ("esp32s3",)
|
||||
|
||||
# Where per-test captures land. One subdirectory per session seed, then per
|
||||
# sanitized test nodeid — identical pattern to other pytest artifacts.
|
||||
CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures"
|
||||
|
||||
|
||||
def _sanitize_nodeid(nodeid: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", nodeid)
|
||||
|
||||
|
||||
# ---------- Role gating ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ui_capable_role(request: pytest.FixtureRequest, hub_devices: dict[str, Any]) -> str:
|
||||
"""Resolve the single role the UI tier drives.
|
||||
|
||||
Today that's `esp32s3`. Skips if the hub doesn't have one. A future
|
||||
multi-screen hub could pick a role per parametrization.
|
||||
"""
|
||||
for role in UI_CAPABLE_ROLES:
|
||||
if role in hub_devices:
|
||||
return role
|
||||
pytest.skip(
|
||||
f"no UI-capable role on hub; need one of {UI_CAPABLE_ROLES} in {sorted(hub_devices)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ui_port(ui_capable_role: str, hub_devices: dict[str, Any]) -> str:
|
||||
port = (
|
||||
hub_devices[ui_capable_role].get("port")
|
||||
if isinstance(hub_devices[ui_capable_role], dict)
|
||||
else hub_devices[ui_capable_role]
|
||||
)
|
||||
if not port:
|
||||
pytest.skip(f"{ui_capable_role!r} has no usable port")
|
||||
return port
|
||||
|
||||
|
||||
# ---------- Camera + OCR session fixtures ---------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def camera(ui_capable_role_session: str | None) -> Iterator[camera_mod.CameraBackend]:
|
||||
"""Session-scoped camera backend. Closed at teardown.
|
||||
|
||||
Backend + device selected by env vars (see `meshtastic_mcp.camera`).
|
||||
Falls through to `NullBackend` when no camera is configured, so the
|
||||
tests run end-to-end on machines without hardware; they just won't
|
||||
have useful images.
|
||||
"""
|
||||
role = ui_capable_role_session or "esp32s3"
|
||||
cam = camera_mod.get_camera(role)
|
||||
try:
|
||||
yield cam
|
||||
finally:
|
||||
cam.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ui_capable_role_session(hub_devices: dict[str, Any]) -> str | None:
|
||||
"""Session-scoped lookup mirroring `ui_capable_role` but non-skipping.
|
||||
|
||||
Used by the `camera` session fixture so it doesn't depend on a
|
||||
test-scoped skip.
|
||||
"""
|
||||
for role in UI_CAPABLE_ROLES:
|
||||
if role in hub_devices:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _ocr_warm() -> None:
|
||||
"""Pay easyocr's ~100 MB / cold-start cost ONCE per session.
|
||||
|
||||
Subsequent `ocr_text()` calls hit the cached reader and return quickly.
|
||||
Swallows errors — if OCR isn't installed, warm is a no-op.
|
||||
"""
|
||||
try:
|
||||
ocr_mod.warm()
|
||||
except Exception: # noqa: BLE001 — belt: never block the suite on OCR init
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _ui_screen_kept_on(
|
||||
ui_capable_role_session: str | None, hub_devices: dict[str, Any]
|
||||
) -> Iterator[None]:
|
||||
"""Keep the OLED on throughout the UI tier so input events aren't dropped.
|
||||
|
||||
Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122)
|
||||
silently DROPS any event that arrives while the screen is off — it just
|
||||
wakes the screen and returns. Every first event in each test would
|
||||
disappear. We set `display.screen_on_secs = 86400` at session start
|
||||
(effectively "always on" for the test window) and restore the prior
|
||||
value at teardown.
|
||||
"""
|
||||
if ui_capable_role_session is None:
|
||||
yield
|
||||
return
|
||||
|
||||
hub_entry = hub_devices[ui_capable_role_session]
|
||||
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
|
||||
if not port:
|
||||
yield
|
||||
return
|
||||
|
||||
original: int | None = None
|
||||
try:
|
||||
current = admin_mod.get_config(section="display", port=port)
|
||||
original = int(
|
||||
current.get("config", {}).get("display", {}).get("screen_on_secs") or 0
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
try:
|
||||
admin_mod.set_config("display.screen_on_secs", 86400, port=port)
|
||||
# Send one wake event so the screen is actually ON going into the
|
||||
# first test. The event itself gets dropped (screenWasOff), but the
|
||||
# wake side-effect sticks.
|
||||
try:
|
||||
admin_mod.send_input_event(event_code=int(InputEventCode.FN_F1), port=port)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1.5) # Let the screen finish its wake transition.
|
||||
except (
|
||||
Exception
|
||||
): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error
|
||||
pass
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if original is not None:
|
||||
try:
|
||||
admin_mod.set_config("display.screen_on_secs", original, port=port)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
# ---------- Per-test capture + transcript ----------------------------------
|
||||
|
||||
|
||||
class FrameCapture:
|
||||
"""Per-test capture recorder. Created once per test via the
|
||||
`frame_capture` fixture; call with a label to snapshot the screen.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cam: camera_mod.CameraBackend,
|
||||
dir_path: Path,
|
||||
lines: list[str],
|
||||
nodeid: str,
|
||||
) -> None:
|
||||
self._cam = cam
|
||||
self._dir = dir_path
|
||||
self._lines = lines
|
||||
self._nodeid = nodeid
|
||||
self._step = 0
|
||||
self.captures: list[dict[str, Any]] = []
|
||||
self._transcript_path = dir_path / "transcript.md"
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
self._transcript_path.write_text(
|
||||
f"# {nodeid} — {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def __call__(self, label: str) -> dict[str, Any]:
|
||||
self._step += 1
|
||||
stem = f"{self._step:03d}-{re.sub(r'[^a-zA-Z0-9_-]+', '-', label)}"
|
||||
png_path = self._dir / f"{stem}.png"
|
||||
ocr_path = self._dir / f"{stem}.ocr.txt"
|
||||
|
||||
try:
|
||||
png = self._cam.capture()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
png = b""
|
||||
ocr_str = f"[capture error: {exc}]"
|
||||
else:
|
||||
camera_mod.save_capture(png, png_path)
|
||||
try:
|
||||
ocr_str = ocr_mod.ocr_text(png)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ocr_str = f"[ocr error: {exc}]"
|
||||
ocr_path.write_text(ocr_str or "", encoding="utf-8")
|
||||
|
||||
frame = get_current_frame(self._lines)
|
||||
entry: dict[str, Any] = {
|
||||
"step": self._step,
|
||||
"label": label,
|
||||
"png_path": str(png_path) if png else None,
|
||||
"ocr_text": ocr_str,
|
||||
"frame": (
|
||||
{
|
||||
"idx": frame.idx,
|
||||
"name": frame.name,
|
||||
"reason": frame.reason,
|
||||
}
|
||||
if frame is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
self.captures.append(entry)
|
||||
|
||||
with self._transcript_path.open("a", encoding="utf-8") as fh:
|
||||
frame_str = (
|
||||
f"frame {frame.idx}/{frame.count} name={frame.name} reason={frame.reason}"
|
||||
if frame is not None
|
||||
else "frame <none>"
|
||||
)
|
||||
ocr_summary = (ocr_str or "").replace("\n", " / ")[:80]
|
||||
fh.write(
|
||||
f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n"
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frame_capture(
|
||||
request: pytest.FixtureRequest,
|
||||
camera: camera_mod.CameraBackend,
|
||||
session_seed: str,
|
||||
) -> Iterator[FrameCapture]:
|
||||
nodeid = _sanitize_nodeid(request.node.nodeid)
|
||||
dir_path = CAPTURES_ROOT / session_seed / nodeid
|
||||
# Fresh directory per test run so reruns don't mix old and new images.
|
||||
if dir_path.exists():
|
||||
shutil.rmtree(dir_path)
|
||||
|
||||
lines = getattr(request.node, "_debug_log_buffer", [])
|
||||
fc = FrameCapture(camera, dir_path, lines, nodeid)
|
||||
# Stash so pytest_runtest_makereport can embed captures in HTML extras.
|
||||
request.node._ui_captures = fc.captures # type: ignore[attr-defined]
|
||||
yield fc
|
||||
|
||||
|
||||
# ---------- Pre-test home-state reset --------------------------------------
|
||||
|
||||
|
||||
def _send_event(port: str, event: InputEventCode) -> None:
|
||||
try:
|
||||
admin_mod.send_input_event(event_code=int(event), port=port)
|
||||
except Exception: # noqa: BLE001
|
||||
# Treat a failed event as soft — the subsequent frame-log assertion
|
||||
# surfaces the real problem with better context.
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ui_home_state(
|
||||
request: pytest.FixtureRequest,
|
||||
hub_devices: dict[str, Any],
|
||||
_ui_screen_kept_on: None,
|
||||
) -> Iterator[None]:
|
||||
"""Before every UI test, jump to frame 0 (usually `home`) via FN_F1 and
|
||||
confirm the device emitted the expected frame log.
|
||||
|
||||
Why FN_F1 (not BACK): FN_F1 maps to `switchToFrame(0)` and ALWAYS
|
||||
produces a `reason=fn_f1` log line, regardless of whatever frame the
|
||||
prior test left us on. BACK is context-sensitive (dismisses overlays
|
||||
on some frames, no-op on others) and can silently fail to transition.
|
||||
|
||||
This fixture doubles as the macro-presence detector: if no `fn_f1`
|
||||
log arrives within 5 s, the firmware almost certainly wasn't baked
|
||||
with `USERPREFS_UI_TEST_LOG`. Skip the tier with an actionable hint
|
||||
instead of letting every test body fail with a confusing assertion.
|
||||
|
||||
Autouse scope is restricted to `tests/ui/` by virtue of this fixture
|
||||
living in that directory's conftest.py — no explicit nodeid guard
|
||||
needed (and earlier attempts at one were wrong, matching `/tests/ui/`
|
||||
against a nodeid that has no leading slash).
|
||||
"""
|
||||
role = next((r for r in UI_CAPABLE_ROLES if r in hub_devices), None)
|
||||
if role is None:
|
||||
yield
|
||||
return
|
||||
|
||||
hub_entry = hub_devices[role]
|
||||
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
|
||||
lines: list[str] = getattr(request.node, "_debug_log_buffer", [])
|
||||
start_len = len(lines)
|
||||
|
||||
# First: a wake event. The screen should already be kept on by
|
||||
# `_ui_screen_kept_on`, but belt + suspenders — if it somehow
|
||||
# powered off (sleep after factory_reset, etc.), this first FN_F1
|
||||
# gets dropped by InputBroker's screenWasOff guard. That's fine;
|
||||
# the second FN_F1 below lands cleanly.
|
||||
_send_event(port, InputEventCode.FN_F1)
|
||||
time.sleep(0.4)
|
||||
_send_event(port, InputEventCode.FN_F1)
|
||||
|
||||
# Wait for the fn_f1 transition log. Any new `reason=fn_f1` line
|
||||
# after call-start counts — we don't care about the name (it should
|
||||
# be `home` or `deviceFocused` depending on board-specific frame order).
|
||||
from ._screen_log import wait_for_reason
|
||||
|
||||
try:
|
||||
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
|
||||
except TimeoutError:
|
||||
# One more try — FreeRTOS queue may be draining slowly.
|
||||
_send_event(port, InputEventCode.FN_F1)
|
||||
try:
|
||||
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
|
||||
except TimeoutError:
|
||||
# Look at what the _debug_log_buffer actually contains to
|
||||
# disambiguate "macro off" from "macro on but event lost".
|
||||
frame_lines = [ln for ln in lines[start_len:] if "Screen: frame" in ln]
|
||||
processing_lines = [
|
||||
ln for ln in lines[start_len:] if "Processing input event" in ln
|
||||
]
|
||||
if frame_lines:
|
||||
pytest.skip(
|
||||
f"ui_home_state: events fire but none reach Screen "
|
||||
f"(saw {len(frame_lines)} frame line(s), "
|
||||
f"{len(processing_lines)} admin inject(s)). "
|
||||
f"Device may be in an unusual state — try `--force-bake`."
|
||||
)
|
||||
else:
|
||||
pytest.skip(
|
||||
"ui_home_state: no `Screen: frame` log after FN_F1. "
|
||||
"Firmware not baked with USERPREFS_UI_TEST_LOG — "
|
||||
"run with `--force-bake` to reflash, or verify the "
|
||||
"macro is active in the bake."
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
# ---------- Small helpers reused by tests ---------------------------------
|
||||
|
||||
|
||||
def send_event(
|
||||
port: str, event: InputEventCode | int | str, **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Thin wrapper so tests read `send_event(port, InputEventCode.RIGHT)`."""
|
||||
return admin_mod.send_input_event(event_code=event, port=port, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FrameCapture",
|
||||
"UI_CAPABLE_ROLES",
|
||||
"send_event",
|
||||
"wait_for_frame",
|
||||
"FrameEvent",
|
||||
]
|
||||
|
||||
|
||||
# Make the helpers discoverable to test modules via `from .conftest import …`.
|
||||
# pytest auto-loads conftest.py, but the symbols above are also re-exported
|
||||
# for readability in the test files.
|
||||
@@ -0,0 +1,61 @@
|
||||
"""FN_F1..F5 directly jumps to frame 0..4 via Screen::handleInputEvent.
|
||||
|
||||
Parametrized over the 5 function keys. Each expects a
|
||||
`Screen: frame <idx>/<count> name=... reason=fn_f<k>` log line, with
|
||||
`idx == k-1`. We don't hardcode the frame *name* because the layout
|
||||
depends on which modules are compiled in for this board.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode
|
||||
|
||||
from ._screen_log import get_current_frame, wait_for_reason
|
||||
from .conftest import FrameCapture, send_event
|
||||
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.parametrize(
|
||||
"event,expected_idx,reason",
|
||||
[
|
||||
(InputEventCode.FN_F1, 0, "fn_f1"),
|
||||
(InputEventCode.FN_F2, 1, "fn_f2"),
|
||||
(InputEventCode.FN_F3, 2, "fn_f3"),
|
||||
(InputEventCode.FN_F4, 3, "fn_f4"),
|
||||
(InputEventCode.FN_F5, 4, "fn_f5"),
|
||||
],
|
||||
ids=["FN_F1", "FN_F2", "FN_F3", "FN_F4", "FN_F5"],
|
||||
)
|
||||
def test_fn_jump_direct_frame(
|
||||
ui_port: str,
|
||||
frame_capture: FrameCapture,
|
||||
request: pytest.FixtureRequest,
|
||||
event: InputEventCode,
|
||||
expected_idx: int,
|
||||
reason: str,
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
start = get_current_frame(lines)
|
||||
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
|
||||
assert start.name in (
|
||||
"home",
|
||||
"deviceFocused",
|
||||
), f"setup expected frame 0 landing, got {start.name!r}"
|
||||
frame_capture("initial")
|
||||
|
||||
if start.count <= expected_idx:
|
||||
pytest.skip(
|
||||
f"device has {start.count} frames; FN_F{expected_idx + 1} needs > {expected_idx}"
|
||||
)
|
||||
|
||||
send_event(ui_port, event)
|
||||
time.sleep(0.1)
|
||||
evt = wait_for_reason(lines, reason, timeout_s=5.0)
|
||||
assert evt.idx == expected_idx, (
|
||||
f"FN_F{expected_idx + 1} expected idx={expected_idx}, got {evt.idx} "
|
||||
f"(name={evt.name}, count={evt.count})"
|
||||
)
|
||||
frame_capture(f"after-{reason}")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Out-of-bounds FN_F5 when the device has <5 frames: no crash, idx unchanged.
|
||||
|
||||
`Screen::handleInputEvent` dispatches FN_F5 unconditionally to
|
||||
`ui->switchToFrame(4)`. The OLEDDisplayUi library typically clamps or
|
||||
silently ignores out-of-range indices, but firmware bugs have existed
|
||||
here — this test protects against a regression that would wedge the UI.
|
||||
|
||||
If this test fails, first check: did the device actually crash (Guru
|
||||
Meditation in the log)? Or did switchToFrame accept an OOB index and
|
||||
leave the UI blank?
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode
|
||||
|
||||
from ._screen_log import get_current_frame, wait_for_reason
|
||||
from .conftest import FrameCapture, send_event
|
||||
|
||||
|
||||
@pytest.mark.timeout(90)
|
||||
def test_fn_f5_out_of_bounds(
|
||||
ui_port: str,
|
||||
frame_capture: FrameCapture,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
start = get_current_frame(lines)
|
||||
assert start is not None
|
||||
|
||||
if start.count > 5:
|
||||
pytest.skip(
|
||||
f"device has {start.count} frames; FN_F5 is in-bounds — not testing OOB here"
|
||||
)
|
||||
|
||||
frame_capture("initial-home")
|
||||
send_event(ui_port, InputEventCode.FN_F5)
|
||||
time.sleep(0.5)
|
||||
|
||||
try:
|
||||
wait_for_reason(lines, "fn_f5", timeout_s=3.0)
|
||||
except TimeoutError:
|
||||
# Firmware may have ignored the event entirely — acceptable.
|
||||
pass
|
||||
|
||||
# Capture whatever is on screen (OCR will tell us if something weird
|
||||
# happened). Device must remain responsive — subsequent events should
|
||||
# still land.
|
||||
frame_capture("after-fn_f5-oob")
|
||||
|
||||
# Send a RIGHT to confirm the UI is still alive. If this times out,
|
||||
# the OOB switchToFrame wedged the UI.
|
||||
send_event(ui_port, InputEventCode.RIGHT)
|
||||
post = wait_for_reason(lines, "next", timeout_s=5.0)
|
||||
assert (
|
||||
post is not None
|
||||
), "UI wedged after OOB FN_F5 — RIGHT no longer produces frame log"
|
||||
frame_capture("after-recovery-right")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""SELECT on the home frame opens the home menu; BACK closes it.
|
||||
|
||||
The home menu is an overlay (menuHandler::homeBaseMenu), not a frame
|
||||
transition — so we verify via OCR difference between before/after
|
||||
captures rather than a `Screen: frame` log line. The underlying
|
||||
mechanism is still InputBroker → Screen::handleInputEvent → menu
|
||||
callback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode
|
||||
|
||||
from ._screen_log import get_current_frame
|
||||
from .conftest import FrameCapture, send_event
|
||||
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
def test_select_opens_home_menu(
|
||||
ui_port: str,
|
||||
frame_capture: FrameCapture,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
start = get_current_frame(lines)
|
||||
assert start is not None
|
||||
if start.name not in ("home", "deviceFocused"):
|
||||
pytest.skip(
|
||||
f"SELECT on {start.name!r} doesn't open homeBaseMenu; "
|
||||
"test is only valid when the landing frame is home/deviceFocused"
|
||||
)
|
||||
|
||||
initial = frame_capture("initial")
|
||||
send_event(ui_port, InputEventCode.SELECT)
|
||||
time.sleep(0.8)
|
||||
opened = frame_capture("after-select")
|
||||
|
||||
# The menu is an overlay (not a frame change). We cannot use log
|
||||
# assertion — instead, OCR should differ because a menu list is now
|
||||
# drawn on top.
|
||||
initial_text = (initial.get("ocr_text") or "").strip()
|
||||
opened_text = (opened.get("ocr_text") or "").strip()
|
||||
if initial_text and opened_text:
|
||||
# When OCR is available, require *some* difference between the two
|
||||
# frames — even a single menu title changes the transcribed text.
|
||||
assert initial_text != opened_text, (
|
||||
f"expected OCR diff after SELECT; both read {initial_text!r}. "
|
||||
"If both are empty, check camera alignment + OCR backend."
|
||||
)
|
||||
|
||||
# Back out — the menu dismisses on BACK.
|
||||
send_event(ui_port, InputEventCode.BACK)
|
||||
time.sleep(0.8)
|
||||
closed = frame_capture("after-back")
|
||||
|
||||
# Soft check: OCR after BACK should look different from the menu
|
||||
# (either back to home or onto a previous frame — BACK's exact
|
||||
# behavior when the menu is up vs. not-up varies). We don't assert
|
||||
# equality because OLED rendering is pixel-stable but camera sampling
|
||||
# introduces noise.
|
||||
if opened_text and closed.get("ocr_text"):
|
||||
close_text = (closed.get("ocr_text") or "").strip()
|
||||
assert (
|
||||
close_text != opened_text
|
||||
), f"after BACK, OCR still looks like the menu: {close_text!r}"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Once we navigate to the textMessage frame, UP/DOWN exercises the
|
||||
message-scroll path (or opens CannedMessages on empty devices).
|
||||
|
||||
Weaker than a "no frame change" assertion because on a fresh bench
|
||||
device the message store is usually empty, and the firmware's UP
|
||||
handler in that case launches CannedMessage — which DOES rebuild
|
||||
frames. We just verify the path doesn't crash + produce captures for
|
||||
visual inspection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode
|
||||
|
||||
from ._screen_log import get_current_frame, wait_for_frame
|
||||
from .conftest import FrameCapture, send_event
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_up_down_on_textmessage_survives(
|
||||
ui_port: str,
|
||||
frame_capture: FrameCapture,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
frame_capture("initial")
|
||||
|
||||
# Walk RIGHT until we land on textMessage — up to 15 hops.
|
||||
for _i in range(15):
|
||||
send_event(ui_port, InputEventCode.RIGHT)
|
||||
time.sleep(0.3)
|
||||
current = get_current_frame(lines)
|
||||
if current is not None and current.name == "textMessage":
|
||||
break
|
||||
else:
|
||||
pytest.skip(
|
||||
"couldn't reach textMessage frame within 15 RIGHTs — not present on this board"
|
||||
)
|
||||
|
||||
wait_for_frame(lines, "textMessage", timeout_s=5.0)
|
||||
frame_capture("on-textMessage")
|
||||
|
||||
# UP and DOWN exercise the message-scroll / canned-message-launch path.
|
||||
# Capture after each so the HTML report shows any visual effect.
|
||||
send_event(ui_port, InputEventCode.UP)
|
||||
time.sleep(0.3)
|
||||
frame_capture("after-up")
|
||||
|
||||
send_event(ui_port, InputEventCode.DOWN)
|
||||
time.sleep(0.3)
|
||||
frame_capture("after-down")
|
||||
|
||||
# Soft check: we should still be in a reachable frame (not wedged).
|
||||
# The next test's `ui_home_state` will error out if the device is
|
||||
# unresponsive, so we don't need a stricter guarantee here.
|
||||
final = get_current_frame(lines)
|
||||
assert final is not None, "no frame log after UP/DOWN — event path broke"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""INPUT_BROKER_RIGHT cycles forward through frames; INPUT_BROKER_LEFT backs.
|
||||
|
||||
The simplest UI test: fire N RIGHT events and assert the frame index
|
||||
moves forward by N (modulo frameCount). Each step captures an image +
|
||||
OCR for the HTML report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode
|
||||
|
||||
from ._screen_log import get_current_frame, wait_for_frame
|
||||
from .conftest import FrameCapture, send_event
|
||||
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
def test_input_right_cycles_frames(
|
||||
ui_port: str,
|
||||
frame_capture: FrameCapture,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
start = get_current_frame(lines)
|
||||
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
|
||||
# FN_F1 in ui_home_state lands on frame 0. The name at frame 0 varies
|
||||
# by board (home on heltec-v3, deviceFocused on others) — accept either.
|
||||
assert start.name in (
|
||||
"home",
|
||||
"deviceFocused",
|
||||
), f"setup expected home/deviceFocused at frame 0, got {start.name!r}"
|
||||
|
||||
frame_capture("initial")
|
||||
visited = [start.idx]
|
||||
|
||||
for step in range(4):
|
||||
send_event(ui_port, InputEventCode.RIGHT)
|
||||
# Each RIGHT should bump the frame index by 1. The log fires with
|
||||
# `reason=next` from showFrame(NEXT).
|
||||
before_count = len(list(_frame_events(lines)))
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
if len(list(_frame_events(lines))) > before_count:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
evt = get_current_frame(lines)
|
||||
assert evt is not None
|
||||
assert (
|
||||
evt.reason == "next"
|
||||
), f"step {step}: expected reason=next, got {evt.reason!r}"
|
||||
visited.append(evt.idx)
|
||||
frame_capture(f"after-right-{step + 1}")
|
||||
|
||||
# Sanity: each index should differ from its predecessor.
|
||||
diffs = [visited[i + 1] - visited[i] for i in range(len(visited) - 1)]
|
||||
assert all(
|
||||
d in (1, -(start.count - 1)) for d in diffs
|
||||
), f"expected monotonic +1 steps (or a wrap), got visited={visited} diffs={diffs}"
|
||||
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
def test_input_left_returns_to_home(
|
||||
ui_port: str,
|
||||
frame_capture: FrameCapture,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""After RIGHT×3 + LEFT×3, we should end up back on the starting frame."""
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
start = get_current_frame(lines)
|
||||
assert start is not None
|
||||
start_name = start.name
|
||||
frame_capture("initial")
|
||||
for _ in range(3):
|
||||
send_event(ui_port, InputEventCode.RIGHT)
|
||||
time.sleep(0.3)
|
||||
frame_capture("after-right-3")
|
||||
|
||||
for _ in range(3):
|
||||
send_event(ui_port, InputEventCode.LEFT)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Back to whichever frame we started on (home or deviceFocused).
|
||||
wait_for_frame(lines, start_name, timeout_s=5.0)
|
||||
frame_capture(f"after-left-3-back-{start_name}")
|
||||
|
||||
|
||||
def _frame_events(lines: list[str]) -> Any:
|
||||
from ._screen_log import iter_frame_events
|
||||
|
||||
return iter_frame_events(lines)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""On the nodelist_nodes frame, UP/DOWN scrolls the list via
|
||||
`NodeListRenderer::scrollUp/scrollDown` (src/graphics/Screen.cpp:1779-1788).
|
||||
The firmware returns 0 before notifying observers, so no frame-change
|
||||
log fires. Verify the path doesn't crash and we stay on nodelist_nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode
|
||||
|
||||
from ._screen_log import assert_no_frame_change, get_current_frame, wait_for_frame
|
||||
from .conftest import FrameCapture, send_event
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_up_down_on_nodelist_no_frame_change(
|
||||
ui_port: str,
|
||||
frame_capture: FrameCapture,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
frame_capture("initial")
|
||||
|
||||
# Walk RIGHT until we land on nodelist_nodes.
|
||||
for _i in range(15):
|
||||
send_event(ui_port, InputEventCode.RIGHT)
|
||||
time.sleep(0.3)
|
||||
current = get_current_frame(lines)
|
||||
if current is not None and current.name == "nodelist_nodes":
|
||||
break
|
||||
else:
|
||||
pytest.skip("couldn't reach nodelist_nodes within 15 RIGHTs")
|
||||
|
||||
wait_for_frame(lines, "nodelist_nodes", timeout_s=5.0)
|
||||
frame_capture("on-nodelist")
|
||||
|
||||
# UP/DOWN on nodelist scroll internally + `return 0` before
|
||||
# notifyObservers — no frame-change log. Verify.
|
||||
send_event(ui_port, InputEventCode.UP)
|
||||
assert_no_frame_change(lines, wait_s=1.5)
|
||||
send_event(ui_port, InputEventCode.DOWN)
|
||||
assert_no_frame_change(lines, wait_s=1.5)
|
||||
|
||||
final = get_current_frame(lines)
|
||||
assert (
|
||||
final is not None and final.name == "nodelist_nodes"
|
||||
), f"UP/DOWN moved us off nodelist_nodes; now on {final!r}"
|
||||
frame_capture("after-up-down")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Pin `InputEventCode` values to the firmware `input_broker_event` enum.
|
||||
|
||||
If this test fails, someone changed the firmware enum (or this Python
|
||||
mirror) and they must stay in sync — the admin RPC sends these as u8
|
||||
wire values directly.
|
||||
|
||||
Also exercises `coerce_event_code` for the happy + error paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode, coerce_event_code
|
||||
|
||||
|
||||
class TestInputEventCodeValues:
|
||||
"""These values MUST match src/input/InputBroker.h exactly."""
|
||||
|
||||
def test_navigation_keys(self) -> None:
|
||||
assert int(InputEventCode.UP) == 17
|
||||
assert int(InputEventCode.DOWN) == 18
|
||||
assert int(InputEventCode.LEFT) == 19
|
||||
assert int(InputEventCode.RIGHT) == 20
|
||||
|
||||
def test_action_keys(self) -> None:
|
||||
assert int(InputEventCode.SELECT) == 10
|
||||
assert int(InputEventCode.CANCEL) == 24
|
||||
assert int(InputEventCode.BACK) == 27
|
||||
|
||||
def test_long_press_variants(self) -> None:
|
||||
assert int(InputEventCode.SELECT_LONG) == 11
|
||||
assert int(InputEventCode.UP_LONG) == 12
|
||||
assert int(InputEventCode.DOWN_LONG) == 13
|
||||
|
||||
def test_fn_keys(self) -> None:
|
||||
assert int(InputEventCode.FN_F1) == 0xF1
|
||||
assert int(InputEventCode.FN_F2) == 0xF2
|
||||
assert int(InputEventCode.FN_F3) == 0xF3
|
||||
assert int(InputEventCode.FN_F4) == 0xF4
|
||||
assert int(InputEventCode.FN_F5) == 0xF5
|
||||
|
||||
def test_system_events(self) -> None:
|
||||
assert int(InputEventCode.SHUTDOWN) == 0x9B
|
||||
assert int(InputEventCode.GPS_TOGGLE) == 0x9E
|
||||
assert int(InputEventCode.SEND_PING) == 0xAF
|
||||
|
||||
def test_auto_increment_block(self) -> None:
|
||||
# C enum: `BACK = 27, USER_PRESS, ALT_PRESS, ALT_LONG` → 28, 29, 30.
|
||||
assert int(InputEventCode.USER_PRESS) == 28
|
||||
assert int(InputEventCode.ALT_PRESS) == 29
|
||||
assert int(InputEventCode.ALT_LONG) == 30
|
||||
|
||||
|
||||
class TestCoerceEventCode:
|
||||
def test_int_passthrough(self) -> None:
|
||||
assert coerce_event_code(20) == 20
|
||||
assert coerce_event_code(0) == 0
|
||||
assert coerce_event_code(255) == 255
|
||||
|
||||
def test_enum_passthrough(self) -> None:
|
||||
assert coerce_event_code(InputEventCode.RIGHT) == 20
|
||||
assert coerce_event_code(InputEventCode.FN_F1) == 0xF1
|
||||
|
||||
def test_name_case_insensitive(self) -> None:
|
||||
assert coerce_event_code("right") == 20
|
||||
assert coerce_event_code("RIGHT") == 20
|
||||
assert coerce_event_code("Right") == 20
|
||||
|
||||
def test_input_broker_prefix_stripped(self) -> None:
|
||||
assert coerce_event_code("INPUT_BROKER_FN_F1") == 0xF1
|
||||
assert coerce_event_code("input_broker_select") == 10
|
||||
|
||||
def test_hyphen_and_underscore_equivalence(self) -> None:
|
||||
assert coerce_event_code("fn-f1") == 0xF1
|
||||
|
||||
def test_int_out_of_range_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="u8"):
|
||||
coerce_event_code(256)
|
||||
with pytest.raises(ValueError, match="u8"):
|
||||
coerce_event_code(-1)
|
||||
|
||||
def test_unknown_name_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="unknown event code name"):
|
||||
coerce_event_code("NOT_A_KEY")
|
||||
|
||||
def test_wrong_type_raises(self) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
coerce_event_code(1.5) # type: ignore[arg-type]
|
||||
with pytest.raises(TypeError):
|
||||
coerce_event_code(None) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Pin the `uhubctl` default-output parser against canned real-world samples.
|
||||
|
||||
uhubctl's output format has been stable since v2.x but occasionally adds
|
||||
new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose
|
||||
regexes to tolerate additions; this test keeps us honest.
|
||||
|
||||
Samples captured from:
|
||||
- v2.6.0 on macOS (Homebrew) — two USB2 hubs, one populated with an
|
||||
nRF52 and a CP2102, plus chained USB3 hubs.
|
||||
- v2.5.0 on Linux (hypothetical — reconstructed from the project README).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.uhubctl import (
|
||||
ROLE_VIDS,
|
||||
UhubctlError,
|
||||
parse_list_output,
|
||||
)
|
||||
|
||||
# Actual `uhubctl` stdout on the developer's macOS bench, Apr 2026.
|
||||
_SAMPLE_MACOS_V26 = """\
|
||||
Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
|
||||
Port 1: 0100 power
|
||||
Port 2: 0103 power enable connect [239a:8029 RAKwireless WisCore RAK4631 Board 920456B1E6972262]
|
||||
Port 3: 0103 power enable connect [10c4:ea60 Silicon Labs CP2102 USB to UART Bridge Controller 0001]
|
||||
Port 4: 0100 power
|
||||
Current status for hub 1-2.3 [2109:0817 VIA Labs, Inc. USB3.0 Hub, USB 3.10, 4 ports, ppps]
|
||||
Port 1: 02a0 power 5gbps Rx.Detect
|
||||
Port 2: 02a0 power 5gbps Rx.Detect
|
||||
Port 3: 02a0 power 5gbps Rx.Detect
|
||||
Port 4: 02a0 power 5gbps Rx.Detect
|
||||
Current status for hub 1-1 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
|
||||
Port 1: 0100 power
|
||||
Port 2: 0100 power
|
||||
Port 3: 0503 power highspeed enable connect [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
|
||||
Port 4: 0100 power
|
||||
"""
|
||||
|
||||
|
||||
# Minimal Linux-style sample (fewer hubs, shows a non-PPPS hub).
|
||||
_SAMPLE_LINUX_NONPPPS = """\
|
||||
Current status for hub 2-1.4 [05e3:0608 GenesysLogic USB2.1 Hub, USB 2.10, 4 ports]
|
||||
Port 1: 0507 power highspeed suspend enable connect [239a:0029 Adafruit Feather Bootloader]
|
||||
Port 2: 0100 power
|
||||
Port 3: 0100 power
|
||||
Port 4: 0100 power
|
||||
"""
|
||||
|
||||
|
||||
class TestParseListOutput:
|
||||
def test_parses_macos_sample_hub_count(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
assert len(hubs) == 3
|
||||
|
||||
def test_parses_hub_location_and_vid(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
via_hub = hubs[0]
|
||||
assert via_hub["location"] == "1-1.3"
|
||||
assert via_hub["vid"] == 0x2109
|
||||
assert via_hub["pid"] == 0x2817
|
||||
assert via_hub["ppps"] is True
|
||||
|
||||
def test_parses_port_with_device(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
nrf52_hub = hubs[0]
|
||||
port2 = next(p for p in nrf52_hub["ports"] if p["port"] == 2)
|
||||
assert port2["device_vid"] == 0x239A
|
||||
assert port2["device_pid"] == 0x8029
|
||||
assert "RAKwireless" in port2["device_desc"]
|
||||
|
||||
def test_empty_port_has_no_device(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
nrf52_hub = hubs[0]
|
||||
port1 = next(p for p in nrf52_hub["ports"] if p["port"] == 1)
|
||||
assert port1["device_vid"] is None
|
||||
assert port1["device_pid"] is None
|
||||
assert port1["device_desc"] is None
|
||||
|
||||
def test_ports_count(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
for hub in hubs:
|
||||
assert len(hub["ports"]) == 4 # each sample hub has 4 ports
|
||||
|
||||
def test_non_ppps_hub_flagged(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_LINUX_NONPPPS)
|
||||
assert len(hubs) == 1
|
||||
assert hubs[0]["ppps"] is False
|
||||
|
||||
def test_handles_empty_input(self) -> None:
|
||||
assert parse_list_output("") == []
|
||||
|
||||
def test_handles_malformed_lines_gracefully(self) -> None:
|
||||
# Lines that don't match HUB_RE or PORT_RE are ignored silently.
|
||||
garbage = "uhubctl: warning: something weird\n" + _SAMPLE_LINUX_NONPPPS
|
||||
hubs = parse_list_output(garbage)
|
||||
assert len(hubs) == 1
|
||||
|
||||
|
||||
class TestRoleVids:
|
||||
def test_nrf52_mapped(self) -> None:
|
||||
assert 0x239A in ROLE_VIDS["nrf52"]
|
||||
|
||||
def test_esp32s3_covers_both_vids(self) -> None:
|
||||
# Espressif native USB + CP2102 USB-UART on heltec-v3 boards.
|
||||
assert 0x303A in ROLE_VIDS["esp32s3"]
|
||||
assert 0x10C4 in ROLE_VIDS["esp32s3"]
|
||||
|
||||
|
||||
class TestResolveTargetErrorPaths:
|
||||
def test_unknown_role_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
# Clear any env-var pinning that might make this pass accidentally.
|
||||
for key in (
|
||||
"MESHTASTIC_UHUBCTL_LOCATION_FLUX",
|
||||
"MESHTASTIC_UHUBCTL_PORT_FLUX",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
with pytest.raises(UhubctlError, match="unknown role"):
|
||||
resolve_target("flux")
|
||||
|
||||
def test_invalid_env_port_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "1-1.3")
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "not-an-int")
|
||||
with pytest.raises(UhubctlError, match="not a valid integer"):
|
||||
resolve_target("nrf52")
|
||||
|
||||
def test_env_var_pinning_wins(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
# Env-var pinning should NOT require uhubctl to be running / installed.
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "9-9.9")
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "7")
|
||||
assert resolve_target("nrf52") == ("9-9.9", 7)
|
||||
|
||||
def test_normalize_role_strips_alt_suffix(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
# esp32s3_alt collapses to esp32s3 for env-var lookup.
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_ESP32S3", "2-2")
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_ESP32S3", "3")
|
||||
assert resolve_target("esp32s3_alt") == ("2-2", 3)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass.
|
||||
|
||||
The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`;
|
||||
if the format string changes, this test — and the parser in
|
||||
`tests/ui/_screen_log.py` — have to be updated together.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.ui._screen_log import FRAME_RE, FrameEvent, iter_frame_events
|
||||
|
||||
|
||||
class TestFrameEventParse:
|
||||
def test_exact_firmware_output(self) -> None:
|
||||
raw = "Screen: frame 2/8 name=home reason=next"
|
||||
evt = FrameEvent.parse(raw)
|
||||
assert evt is not None
|
||||
assert evt.idx == 2
|
||||
assert evt.count == 8
|
||||
assert evt.name == "home"
|
||||
assert evt.reason == "next"
|
||||
assert evt.raw == raw
|
||||
|
||||
def test_with_log_prefix(self) -> None:
|
||||
"""Log lines may be preamble-wrapped by the firmware LOG_INFO macro
|
||||
(timestamp, severity, etc.) — the regex uses .search() not .match()
|
||||
so prefixes are fine."""
|
||||
raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 "
|
||||
evt = FrameEvent.parse(raw)
|
||||
assert evt is not None
|
||||
assert evt.idx == 4
|
||||
assert evt.count == 12
|
||||
assert evt.name == "nodelist_nodes"
|
||||
assert evt.reason == "fn_f3"
|
||||
|
||||
def test_rebuild_reason(self) -> None:
|
||||
evt = FrameEvent.parse("Screen: frame 0/5 name=deviceFocused reason=rebuild")
|
||||
assert evt is not None
|
||||
assert evt.reason == "rebuild"
|
||||
|
||||
def test_all_fn_reasons(self) -> None:
|
||||
for k in range(1, 6):
|
||||
evt = FrameEvent.parse(
|
||||
f"Screen: frame {k - 1}/8 name=settings reason=fn_f{k}"
|
||||
)
|
||||
assert evt is not None and evt.reason == f"fn_f{k}"
|
||||
|
||||
def test_unknown_name_is_preserved(self) -> None:
|
||||
"""If the reverse-map returns 'unknown', that still parses cleanly."""
|
||||
evt = FrameEvent.parse("Screen: frame 99/100 name=unknown reason=prev")
|
||||
assert evt is not None and evt.name == "unknown"
|
||||
|
||||
def test_non_matching_line_returns_none(self) -> None:
|
||||
assert FrameEvent.parse("BOOT Booting firmware 2.7.23") is None
|
||||
assert FrameEvent.parse("") is None
|
||||
assert FrameEvent.parse("Screen: without the right format") is None
|
||||
|
||||
|
||||
class TestIterFrameEvents:
|
||||
def test_filters_non_matching_lines(self) -> None:
|
||||
lines = [
|
||||
"Booting...",
|
||||
"Screen: frame 1/5 name=home reason=rebuild",
|
||||
"Some other log line",
|
||||
"Screen: frame 2/5 name=textMessage reason=next",
|
||||
]
|
||||
evts = list(iter_frame_events(lines))
|
||||
assert len(evts) == 2
|
||||
assert evts[0].reason == "rebuild"
|
||||
assert evts[1].reason == "next"
|
||||
|
||||
|
||||
class TestRegexAnchoring:
|
||||
def test_regex_is_compiled(self) -> None:
|
||||
assert FRAME_RE.search("Screen: frame 0/0 name=home reason=next") is not None
|
||||
|
||||
def test_regex_allows_unusual_names(self) -> None:
|
||||
r"""Name is `\S+`, so compound names with underscores/digits match."""
|
||||
m = FRAME_RE.search("Screen: frame 5/10 name=nodelist_hopsignal reason=fn_f2")
|
||||
assert m is not None and m["name"] == "nodelist_hopsignal"
|
||||
+3
-3
@@ -56,7 +56,9 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
||||
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
|
||||
-DMESHTASTIC_EXCLUDE_POWERMON=1
|
||||
-DMESHTASTIC_EXCLUDE_STATUS=1
|
||||
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
|
||||
-DLED_BUILTIN=-1
|
||||
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
|
||||
#-D OLED_PL=1
|
||||
#-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs
|
||||
@@ -124,7 +126,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/5305670b68eb5b92d14e62b5b536969ca4bb441f.zip
|
||||
https://github.com/meshtastic/device-ui/archive/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip
|
||||
|
||||
; Common libs for environmental measurements in telemetry module
|
||||
[environmental_base]
|
||||
@@ -225,8 +227,6 @@ lib_deps =
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
|
||||
+1
-1
Submodule protobufs updated: e30092e616...4d5b500df5
@@ -4,8 +4,7 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
||||
bool usePreset)
|
||||
{
|
||||
|
||||
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
|
||||
// rely on this being a stable literal for channel-name hashing and default-channel detection.
|
||||
// If use_preset is false, always return "Custom"
|
||||
if (!usePreset) {
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
+5
-34
@@ -40,22 +40,6 @@
|
||||
#include "concurrency/LockGuard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
|
||||
#include "stm32yyxx_ll_adc.h"
|
||||
|
||||
/* Analog read resolution */
|
||||
#if defined(LL_ADC_RESOLUTION_12B)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_RESOLUTION_12B
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#elif defined(LL_ADC_DS_DATA_WIDTH_12_BIT)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_DS_DATA_WIDTH_12_BIT
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#else
|
||||
#error "ADC resolution could not be defined!"
|
||||
#endif
|
||||
#define ADC_RANGE (1 << BATTERY_SENSE_RESOLUTION_BITS)
|
||||
#endif
|
||||
|
||||
#if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
#include "target_specific.h"
|
||||
@@ -344,17 +328,11 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_STM32WL
|
||||
// STM32 ADC with VREFINT runtime calibration
|
||||
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
|
||||
raw = analogRead(BATTERY_PIN);
|
||||
scaled = __LL_ADC_CALC_DATA_TO_VOLTAGE(Vref, raw, LL_ADC_RESOLUTION);
|
||||
scaled *= operativeAdcMultiplier;
|
||||
#elif defined(ARCH_ESP32) // ADC block for espressif platforms
|
||||
#ifdef ARCH_ESP32 // ADC block for espressif platforms
|
||||
raw = espAdcRead();
|
||||
scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs);
|
||||
scaled *= operativeAdcMultiplier;
|
||||
#else // block for all other platforms
|
||||
#else // block for all other platforms
|
||||
#ifdef ARCH_NRF52
|
||||
concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock);
|
||||
#endif
|
||||
@@ -552,11 +530,6 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
bool initial_read_done = false;
|
||||
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
||||
uint32_t last_read_time_ms = 0;
|
||||
#ifdef ARCH_STM32WL
|
||||
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
|
||||
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
|
||||
uint32_t Vref = 3300;
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT)
|
||||
|
||||
@@ -666,9 +639,7 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
|
||||
#ifdef ARCH_ESP32 // ESP32 needs special analog stuff
|
||||
|
||||
#ifndef ADC_WIDTH // max resolution by default
|
||||
static const adc_bits_width_t width = ADC_WIDTH_BIT_12;
|
||||
@@ -678,7 +649,7 @@ bool Power::analogInit()
|
||||
#ifndef BAT_MEASURE_ADC_UNIT // ADC1
|
||||
adc1_config_width(width);
|
||||
adc1_config_channel_atten(adc_channel, atten);
|
||||
#else // ADC2
|
||||
#else // ADC2
|
||||
adc2_config_channel_atten(adc_channel, atten);
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32S3
|
||||
// ADC2 wifi bug workaround
|
||||
@@ -708,7 +679,7 @@ bool Power::analogInit()
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
#ifndef ARCH_ESP32
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, millis() % 1000);
|
||||
::printf("| %02d:%02d:%02d %u ", hour, min, sec, millis() / 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
@@ -151,7 +151,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000);
|
||||
::printf("| ??:??:?? %u ", millis() / 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
|
||||
@@ -30,9 +30,6 @@ SerialConsole *console;
|
||||
|
||||
void consoleInit()
|
||||
{
|
||||
if (console) {
|
||||
return;
|
||||
}
|
||||
auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread
|
||||
|
||||
#if defined(SERIAL_HAS_ON_RECEIVE)
|
||||
|
||||
+3
-9
@@ -78,11 +78,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Pre-hop drop handling (compile-time flag).
|
||||
#ifndef MESHTASTIC_PREHOP_DROP
|
||||
#define MESHTASTIC_PREHOP_DROP 0
|
||||
#endif
|
||||
|
||||
/// Convert a preprocessor name into a quoted string
|
||||
#define xstr(s) ystr(s)
|
||||
#define ystr(s) #s
|
||||
@@ -231,7 +226,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define BME_ADDR 0x76
|
||||
#define BME_ADDR_ALTERNATE 0x77
|
||||
#define MCP9808_ADDR 0x18
|
||||
#define INA_ADDR 0x40 // same as SHT2X
|
||||
#define INA_ADDR 0x40
|
||||
#define INA_ADDR_ALTERNATE 0x41
|
||||
#define INA_ADDR_WAVESHARE_UPS 0x43
|
||||
#define INA3221_ADDR 0x42
|
||||
@@ -244,8 +239,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define LPS22HB_ADDR 0x5C
|
||||
#define LPS22HB_ADDR_ALT 0x5D
|
||||
#define SFA30_ADDR 0x5D
|
||||
#define SHTXX_ADDR 0x44
|
||||
#define SHTXX_ADDR_ALT 0x45
|
||||
#define SHT31_4x_ADDR 0x44
|
||||
#define SHT31_4x_ADDR_ALT 0x45
|
||||
#define PMSA003I_ADDR 0x12
|
||||
#define QMA6100P_ADDR 0x12
|
||||
#define AHT10_ADDR 0x38
|
||||
@@ -515,7 +510,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
|
||||
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
|
||||
#define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1
|
||||
#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1
|
||||
#define MESHTASTIC_EXCLUDE_ATAK 1
|
||||
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
|
||||
#define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1
|
||||
|
||||
@@ -31,6 +31,9 @@ class ScanI2C
|
||||
INA3221,
|
||||
MAX17048,
|
||||
MCP9808,
|
||||
SHT31,
|
||||
SHT4X,
|
||||
SHTC3,
|
||||
LPS22HB,
|
||||
QMC6310U,
|
||||
QMC6310N,
|
||||
@@ -86,13 +89,13 @@ class ScanI2C
|
||||
DA217,
|
||||
CHSC6X,
|
||||
CST226SE,
|
||||
CST3530,
|
||||
BMI270,
|
||||
SEN5X,
|
||||
SFA30,
|
||||
CW2015,
|
||||
SCD30,
|
||||
ADS1115,
|
||||
SHTXX
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
|
||||
@@ -136,9 +136,7 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
|
||||
return match;
|
||||
}
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
// FIXME Move to a separate file for detection of sensors that require more complex interactions?
|
||||
// For SEN5X detection
|
||||
/// for SEN5X detection
|
||||
// Note, this code needs to be called before setting the I2C bus speed
|
||||
// for the screen at high speed. The speed needs to be at 100kHz, otherwise
|
||||
// detection will not work
|
||||
@@ -176,46 +174,6 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address)
|
||||
|
||||
return String(productName);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
{
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFA);
|
||||
i2cBus->write(0x0F);
|
||||
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)8) != 8)
|
||||
return false;
|
||||
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 8) {
|
||||
i2cBus->read();
|
||||
}
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFC);
|
||||
i2cBus->write(0xC9);
|
||||
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)6) != 6)
|
||||
return false;
|
||||
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 6) {
|
||||
i2cBus->read();
|
||||
}
|
||||
|
||||
// Assume we detect the SHT21 if something came back from the request
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
||||
case ADDR: \
|
||||
@@ -413,7 +371,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
#endif
|
||||
#if !defined(M5STACK_UNITC6L)
|
||||
case INA_ADDR: // Same as SHT2X
|
||||
case INA_ADDR:
|
||||
case INA_ADDR_ALTERNATE:
|
||||
case INA_ADDR_WAVESHARE_UPS:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
@@ -429,12 +387,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("INA260", (uint8_t)addr.address);
|
||||
type = INA260;
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
} else if (detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
|
||||
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
|
||||
type = SHTXX;
|
||||
#endif
|
||||
} else { // Assume INA219 if none of the above ones are found
|
||||
} else { // Assume INA219 if INA260 ID is not found
|
||||
logFoundDevice("INA219", (uint8_t)addr.address);
|
||||
type = INA219;
|
||||
}
|
||||
@@ -495,19 +448,22 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SHTXX_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHTXX_ADDR_ALT: // same as OPT3001_ADDR
|
||||
case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR
|
||||
if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
|
||||
type = OPT3001;
|
||||
logFoundDevice("OPT3001", (uint8_t)addr.address);
|
||||
} else { // SHTXX
|
||||
type = SHTXX;
|
||||
logFoundDevice("SHTXX", (uint8_t)addr.address);
|
||||
} else if (i2cCommandResponseLength(addr, 0x89, 6)) { // SHT4x serial number (6 bytes inc. CRC)
|
||||
type = SHT4X;
|
||||
logFoundDevice("SHT4X", (uint8_t)addr.address);
|
||||
} else {
|
||||
type = SHT31;
|
||||
logFoundDevice("SHT31", (uint8_t)addr.address);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address)
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address)
|
||||
case RCWL9620_ADDR:
|
||||
// get MAX30102 PARTID
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1);
|
||||
@@ -629,7 +585,31 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address);
|
||||
SCAN_SIMPLE_CASE(SCD30_ADDR, SCD30, "SCD30", (uint8_t)addr.address);
|
||||
case CST328_ADDR:
|
||||
// Do we have the CST328 or the CST226SE
|
||||
// Do we have the CST328 or the CST226SE,CST3530
|
||||
{
|
||||
// T-Deck pro V1.1 new touch panel use CST3530
|
||||
int retry = 5;
|
||||
while (retry--) {
|
||||
uint8_t buffer[7];
|
||||
uint8_t r_cmd[] = {0x0d0, 0x03, 0x00, 0x00};
|
||||
i2cBus->beginTransmission(addr.address);
|
||||
i2cBus->write(r_cmd, sizeof(r_cmd));
|
||||
if (i2cBus->endTransmission() == 0) {
|
||||
i2cBus->requestFrom((int)addr.address, 7);
|
||||
i2cBus->readBytes(buffer, 7);
|
||||
if (buffer[2] == 0xCA && buffer[3] == 0xCA) {
|
||||
logFoundDevice("CST3530", (uint8_t)addr.address);
|
||||
type = CST3530;
|
||||
break;
|
||||
}
|
||||
}
|
||||
uint8_t cmd1[] = {0xD0, 0x00, 0x04, 0x00};
|
||||
i2cBus->beginTransmission(addr.address);
|
||||
i2cBus->write(cmd1, sizeof(cmd1));
|
||||
i2cBus->endTransmission();
|
||||
delay(50);
|
||||
}
|
||||
}
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xAB), 1);
|
||||
if (registerValue == 0xA9) {
|
||||
type = CST226SE;
|
||||
@@ -719,7 +699,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
break;
|
||||
} else {
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
String prod = "";
|
||||
prod = readSEN5xProductName(i2cBus, addr.address);
|
||||
if (prod.startsWith("SEN55")) {
|
||||
@@ -735,7 +714,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("Sensirion SEN50", addr.address);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
if (addr.address == BMX160_ADDR) {
|
||||
type = BMX160;
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
|
||||
@@ -103,14 +103,6 @@ static int32_t gpsSwitch()
|
||||
if (gps) {
|
||||
int currentState = digitalRead(PIN_GPS_SWITCH);
|
||||
|
||||
// Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS.
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
|
||||
gps->disable();
|
||||
lastState = currentState;
|
||||
firstrun = false;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
// if the switch is set to zero, disable the GPS Thread
|
||||
if (firstrun)
|
||||
if (currentState == LOW)
|
||||
|
||||
@@ -104,8 +104,13 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit)
|
||||
// End the update process - virtual method, overridden in derived class
|
||||
void EInkDisplay::endUpdate()
|
||||
{
|
||||
// Power off display hardware, then deep-sleep (Except Wireless Paper V1.1, no deep-sleep)
|
||||
#ifndef EINK_NOT_HIBERNATE
|
||||
// By default, power off the E-Ink display hardware and enter hibernate().
|
||||
// Boards/panels that define EINK_NOT_HIBERNATE intentionally skip this step.
|
||||
// Skipping hibernate() can help avoid panel-specific wake/refresh or ghosting issues,
|
||||
// but it typically trades lower power savings for that compatibility.
|
||||
adafruitDisplay->hibernate();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Write the buffer to the display memory
|
||||
|
||||
+92
-99
@@ -60,7 +60,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "main.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/Default.h"
|
||||
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#include "modules/TextMessageModule.h"
|
||||
@@ -99,7 +98,6 @@ namespace graphics
|
||||
|
||||
// This means the *visible* area (sh1106 can address 132, but shows 128 for example)
|
||||
#define IDLE_FRAMERATE 1 // in fps
|
||||
#define COMPASS_ACTIVE_FRAMERATE 20
|
||||
|
||||
// DEBUG
|
||||
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
|
||||
@@ -137,60 +135,6 @@ static bool heartbeat = false;
|
||||
|
||||
extern bool hasUnreadMessage;
|
||||
|
||||
static inline float wrapHeading360(float heading)
|
||||
{
|
||||
if (heading < 0.0f) {
|
||||
heading += 360.0f;
|
||||
} else if (heading >= 360.0f) {
|
||||
heading -= 360.0f;
|
||||
}
|
||||
return heading;
|
||||
}
|
||||
|
||||
void Screen::setHeading(float heading)
|
||||
{
|
||||
const float wrappedHeading = wrapHeading360(heading);
|
||||
|
||||
if (!hasCompass) {
|
||||
hasCompass = true;
|
||||
compassHeading = wrappedHeading;
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
|
||||
float delta = wrappedHeading - compassHeading;
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
|
||||
// Adaptive filtering:
|
||||
// - Strong damping for tiny deltas (jitter)
|
||||
// - Faster response for larger turns
|
||||
const float absDelta = (delta >= 0.0f) ? delta : -delta;
|
||||
if (absDelta < 1.0f) {
|
||||
return;
|
||||
}
|
||||
|
||||
float alpha = 0.35f;
|
||||
if (absDelta > 25.0f) {
|
||||
alpha = 0.85f;
|
||||
} else if (absDelta > 10.0f) {
|
||||
alpha = 0.65f;
|
||||
}
|
||||
|
||||
float step = delta * alpha;
|
||||
const float maxStep = 12.0f;
|
||||
if (step > maxStep) {
|
||||
step = maxStep;
|
||||
} else if (step < -maxStep) {
|
||||
step = -maxStep;
|
||||
}
|
||||
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// Overlay Alert Banner Renderer
|
||||
// ==============================
|
||||
@@ -328,25 +272,10 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
|
||||
float Screen::estimatedHeading(double lat, double lon)
|
||||
{
|
||||
static double oldLat, oldLon;
|
||||
static float b = -1.0f;
|
||||
static uint32_t lastHeadingAtMs = 0;
|
||||
const uint32_t now = millis();
|
||||
const uint32_t gpsUpdateIntervalSecs =
|
||||
Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval);
|
||||
uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs;
|
||||
if (config.position.position_broadcast_smart_enabled) {
|
||||
const uint32_t smartMinIntervalSecs = Default::getConfiguredOrDefault(
|
||||
config.position.broadcast_smart_minimum_interval_secs, default_broadcast_smart_minimum_interval_secs);
|
||||
if (smartMinIntervalSecs > effectiveUpdateIntervalSecs) {
|
||||
effectiveUpdateIntervalSecs = smartMinIntervalSecs;
|
||||
}
|
||||
}
|
||||
// Two expected update windows; keep arithmetic 32-bit to avoid pulling in larger 64-bit helpers.
|
||||
const uint32_t headingStaleMs =
|
||||
(effectiveUpdateIntervalSecs > (UINT32_MAX / 2000U)) ? UINT32_MAX : (effectiveUpdateIntervalSecs * 2000U);
|
||||
static float b;
|
||||
|
||||
if (oldLat == 0) {
|
||||
// Need at least two position points before we can infer heading.
|
||||
// just prepare for next time
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
|
||||
@@ -354,20 +283,12 @@ float Screen::estimatedHeading(double lat, double lon)
|
||||
}
|
||||
|
||||
float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon);
|
||||
if (d < 10) { // haven't moved enough, keep previous heading (invalid until first real movement)
|
||||
if (lastHeadingAtMs != 0 && (now - lastHeadingAtMs) >= headingStaleMs) {
|
||||
// Heading is stale after prolonged no-movement; force reacquire.
|
||||
b = -1.0f;
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
}
|
||||
if (d < 10) // haven't moved enough, just keep current bearing
|
||||
return b;
|
||||
}
|
||||
|
||||
b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG;
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
lastHeadingAtMs = now;
|
||||
|
||||
return b;
|
||||
}
|
||||
@@ -1002,22 +923,9 @@ int32_t Screen::runOnce()
|
||||
// but we should only call setTargetFPS when framestate changes, because
|
||||
// otherwise that breaks animations.
|
||||
|
||||
uint32_t desiredFramerate = IDLE_FRAMERATE;
|
||||
#if HAS_GPS && !defined(USE_EINK)
|
||||
if (showingNormalScreen && hasCompass) {
|
||||
const uint8_t currentFrame = ui->getUiState()->currentFrame;
|
||||
if ((framesetInfo.positions.gps != 255 && currentFrame == framesetInfo.positions.gps) ||
|
||||
(framesetInfo.positions.waypoint != 255 && currentFrame == framesetInfo.positions.waypoint) ||
|
||||
(framesetInfo.positions.firstFavorite != 255 && currentFrame >= framesetInfo.positions.firstFavorite &&
|
||||
currentFrame <= framesetInfo.positions.lastFavorite)) {
|
||||
desiredFramerate = COMPASS_ACTIVE_FRAMERATE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (targetFramerate != desiredFramerate && ui->getUiState()->frameState == FIXED) {
|
||||
if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) {
|
||||
// oldFrameState = ui->getUiState()->frameState;
|
||||
targetFramerate = desiredFramerate;
|
||||
targetFramerate = IDLE_FRAMERATE;
|
||||
|
||||
ui->setTargetFPS(targetFramerate);
|
||||
forceDisplay();
|
||||
@@ -1289,7 +1197,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||||
if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1318,7 +1226,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
|
||||
prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed)
|
||||
prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed)
|
||||
|
||||
// Focus on a specific frame, in the frame set we just created
|
||||
switch (focus) {
|
||||
@@ -1357,6 +1265,10 @@ void Screen::setFrames(FrameFocus focus)
|
||||
// Store the info about this frameset, for future setFrames calls
|
||||
this->framesetInfo = fsi;
|
||||
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
logFrameChange("rebuild", ui->getUiState()->currentFrame);
|
||||
#endif
|
||||
|
||||
setFastFramerate(); // Draw ASAP
|
||||
}
|
||||
|
||||
@@ -1511,11 +1423,77 @@ void Screen::handleOnPress()
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
void Screen::logFrameChange(const char *reason, uint8_t targetIdx)
|
||||
{
|
||||
// Reverse-map an index to a stable name string keyed off FramePositions
|
||||
// field names — so the pytest harness can assert `name=nodelist_nodes`
|
||||
// without caring about how the positions were ordered this boot.
|
||||
const auto &p = framesetInfo.positions;
|
||||
const char *name = "unknown";
|
||||
if (targetIdx == p.home)
|
||||
name = "home";
|
||||
else if (targetIdx == p.deviceFocused)
|
||||
name = "deviceFocused";
|
||||
else if (targetIdx == p.textMessage)
|
||||
name = "textMessage";
|
||||
else if (targetIdx == p.nodelist_nodes)
|
||||
name = "nodelist_nodes";
|
||||
else if (targetIdx == p.nodelist_location)
|
||||
name = "nodelist_location";
|
||||
else if (targetIdx == p.nodelist_lastheard)
|
||||
name = "nodelist_lastheard";
|
||||
else if (targetIdx == p.nodelist_hopsignal)
|
||||
name = "nodelist_hopsignal";
|
||||
else if (targetIdx == p.nodelist_distance)
|
||||
name = "nodelist_distance";
|
||||
else if (targetIdx == p.nodelist_bearings)
|
||||
name = "nodelist_bearings";
|
||||
else if (targetIdx == p.system)
|
||||
name = "system";
|
||||
else if (targetIdx == p.gps)
|
||||
name = "gps";
|
||||
else if (targetIdx == p.lora)
|
||||
name = "lora";
|
||||
else if (targetIdx == p.clock)
|
||||
name = "clock";
|
||||
else if (targetIdx == p.chirpy)
|
||||
name = "chirpy";
|
||||
else if (targetIdx == p.fault)
|
||||
name = "fault";
|
||||
else if (targetIdx == p.waypoint)
|
||||
name = "waypoint";
|
||||
else if (targetIdx == p.focusedModule)
|
||||
name = "focusedModule";
|
||||
else if (targetIdx == p.log)
|
||||
name = "log";
|
||||
else if (targetIdx == p.settings)
|
||||
name = "settings";
|
||||
else if (targetIdx == p.wifi)
|
||||
name = "wifi";
|
||||
else if (p.firstFavorite != 255 && p.lastFavorite != 255 && targetIdx >= p.firstFavorite && targetIdx <= p.lastFavorite)
|
||||
name = "favorite";
|
||||
LOG_INFO("Screen: frame %u/%u name=%s reason=%s", (unsigned)targetIdx, (unsigned)framesetInfo.frameCount, name, reason);
|
||||
}
|
||||
#endif
|
||||
|
||||
void Screen::showFrame(FrameDirection direction)
|
||||
{
|
||||
// Only advance frames when UI is stable
|
||||
if (ui->getUiState()->frameState == FIXED) {
|
||||
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
// Log the *intended* target before the (async) transition fires, so
|
||||
// tests see a deterministic record of what was requested.
|
||||
if (framesetInfo.frameCount > 0) {
|
||||
uint8_t curr = ui->getUiState()->currentFrame;
|
||||
uint8_t target = (direction == FrameDirection::NEXT)
|
||||
? (uint8_t)((curr + 1) % framesetInfo.frameCount)
|
||||
: (uint8_t)((curr + framesetInfo.frameCount - 1) % framesetInfo.frameCount);
|
||||
logFrameChange(direction == FrameDirection::NEXT ? "next" : "prev", target);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (direction == FrameDirection::NEXT) {
|
||||
ui->nextFrame();
|
||||
} else {
|
||||
@@ -1847,22 +1825,37 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
showFrame(FrameDirection::NEXT);
|
||||
} else if (event->inputEvent == INPUT_BROKER_FN_F1) {
|
||||
this->ui->switchToFrame(0);
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
logFrameChange("fn_f1", 0);
|
||||
#endif
|
||||
lastScreenTransition = millis();
|
||||
setFastFramerate();
|
||||
} else if (event->inputEvent == INPUT_BROKER_FN_F2) {
|
||||
this->ui->switchToFrame(1);
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
logFrameChange("fn_f2", 1);
|
||||
#endif
|
||||
lastScreenTransition = millis();
|
||||
setFastFramerate();
|
||||
} else if (event->inputEvent == INPUT_BROKER_FN_F3) {
|
||||
this->ui->switchToFrame(2);
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
logFrameChange("fn_f3", 2);
|
||||
#endif
|
||||
lastScreenTransition = millis();
|
||||
setFastFramerate();
|
||||
} else if (event->inputEvent == INPUT_BROKER_FN_F4) {
|
||||
this->ui->switchToFrame(3);
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
logFrameChange("fn_f4", 3);
|
||||
#endif
|
||||
lastScreenTransition = millis();
|
||||
setFastFramerate();
|
||||
} else if (event->inputEvent == INPUT_BROKER_FN_F5) {
|
||||
this->ui->switchToFrame(4);
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
logFrameChange("fn_f5", 4);
|
||||
#endif
|
||||
lastScreenTransition = millis();
|
||||
setFastFramerate();
|
||||
} else if (event->inputEvent == INPUT_BROKER_UP_LONG) {
|
||||
|
||||
+17
-3
@@ -330,11 +330,15 @@ class Screen : public concurrency::OSThread
|
||||
|
||||
// Function to allow the AccelerometerThread to set the heading if a sensor provides it
|
||||
// Mutex needed?
|
||||
void setHeading(float heading);
|
||||
void setHeading(long _heading)
|
||||
{
|
||||
hasCompass = true;
|
||||
compassHeading = fmod(_heading, 360);
|
||||
}
|
||||
|
||||
bool hasHeading() { return hasCompass; }
|
||||
|
||||
float getHeading() { return compassHeading; }
|
||||
long getHeading() { return compassHeading; }
|
||||
|
||||
void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; }
|
||||
uint32_t getEndCalibration() { return endCalibrationAt; }
|
||||
@@ -669,6 +673,16 @@ class Screen : public concurrency::OSThread
|
||||
void handleOnPress();
|
||||
void handleStartFirmwareUpdateScreen();
|
||||
|
||||
#ifdef USERPREFS_UI_TEST_LOG
|
||||
// Test-only: emits one LOG_INFO line on every frame transition so the
|
||||
// pytest harness can assert which frame is shown. Gated behind a macro
|
||||
// so the chatty log doesn't ship in release builds. Enabled via
|
||||
// build_testing_profile(enable_ui_log=True) in mcp-server/userprefs.py.
|
||||
// Member function (not free) because FramesetInfo is a private nested
|
||||
// type — only methods of Screen can reach it.
|
||||
void logFrameChange(const char *reason, uint8_t targetIdx);
|
||||
#endif
|
||||
|
||||
// Info collected by setFrames method.
|
||||
// Index location of specific frames.
|
||||
// - Used to apply the FrameFocus parameter of setFrames
|
||||
@@ -778,4 +792,4 @@ extern std::vector<std::string> functionSymbol;
|
||||
extern std::string functionSymbolString;
|
||||
extern graphics::Screen *screen;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -1254,14 +1254,14 @@ void TFTDisplay::display(bool fromBlank)
|
||||
|
||||
// Did we find a pixel that needs updating on this row?
|
||||
if (x_FirstPixelUpdate < displayWidth) {
|
||||
// Align the first pixel for update to an even number so the total alignment of
|
||||
// the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
|
||||
x_FirstPixelUpdate &= ~1;
|
||||
|
||||
// Step 3a: copy rest of the pixels in this row into the pixel line buffer,
|
||||
// while also recording the last pixel in the row that needs updating.
|
||||
// Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set.
|
||||
for (x = x_FirstPixelUpdate; x < displayWidth; x++) {
|
||||
// Quickly write out the first changed pixel (saves another array lookup)
|
||||
linePixelBuffer[x_FirstPixelUpdate] = isset ? colorTftMesh : colorTftBlack;
|
||||
x_LastPixelUpdate = x_FirstPixelUpdate;
|
||||
|
||||
// Step 3: copy all remaining pixels in this row into the pixel line buffer,
|
||||
// while also recording the last pixel in the row that needs updating
|
||||
for (x = x_FirstPixelUpdate + 1; x < displayWidth; x++) {
|
||||
isset = buffer[x + y_byteIndex] & y_byteMask;
|
||||
linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack;
|
||||
|
||||
@@ -1274,14 +1274,6 @@ void TFTDisplay::display(bool fromBlank)
|
||||
x_LastPixelUpdate = x;
|
||||
}
|
||||
}
|
||||
// Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs.
|
||||
// Most displays will have even number of pixels in a row -- this will be in bounds
|
||||
// of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.)
|
||||
x_LastPixelUpdate |= 1;
|
||||
// Ensure the last pixel index does not exceed the display width.
|
||||
if (x_LastPixelUpdate >= displayWidth) {
|
||||
x_LastPixelUpdate = displayWidth - 1;
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
|
||||
(x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#include "CompassRenderer.h"
|
||||
#include "NodeDB.h"
|
||||
#include "UIRenderer.h"
|
||||
#include "configuration.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include <cmath>
|
||||
@@ -17,8 +21,8 @@ struct Point {
|
||||
|
||||
void rotate(float angle)
|
||||
{
|
||||
float cos_a = cosf(angle);
|
||||
float sin_a = sinf(angle);
|
||||
float cos_a = cos(angle);
|
||||
float sin_a = sin(angle);
|
||||
float new_x = x * cos_a - y * sin_a;
|
||||
float new_y = x * sin_a + y * cos_a;
|
||||
x = new_x;
|
||||
@@ -47,30 +51,21 @@ void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY,
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
radius += 4;
|
||||
}
|
||||
float northX = 0.0f;
|
||||
float northY = -radius;
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) {
|
||||
const float c = cosf(-myHeading);
|
||||
const float s = sinf(-myHeading);
|
||||
const float rx = northX * c - northY * s;
|
||||
const float ry = northX * s + northY * c;
|
||||
northX = rx;
|
||||
northY = ry;
|
||||
}
|
||||
northX += compassX;
|
||||
northY += compassY;
|
||||
Point north(0, -radius);
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
north.rotate(-myHeading);
|
||||
north.translate(compassX, compassY);
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->setColor(BLACK);
|
||||
const int16_t nLabelWidth = display->getStringWidth("N");
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->fillRect(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
|
||||
display->fillRect(north.x - 8, north.y - 1, display->getStringWidth("N") + 3, FONT_HEIGHT_SMALL - 6);
|
||||
} else {
|
||||
display->fillRect(northX - 4, northY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
|
||||
display->fillRect(north.x - 4, north.y - 1, display->getStringWidth("N") + 2, FONT_HEIGHT_SMALL - 6);
|
||||
}
|
||||
display->setColor(WHITE);
|
||||
display->drawString(northX, northY - 3, "N");
|
||||
display->drawString(north.x, north.y - 3, "N");
|
||||
}
|
||||
|
||||
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
|
||||
@@ -118,46 +113,11 @@ void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, f
|
||||
display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y);
|
||||
}
|
||||
|
||||
bool getHeadingRadians(double lat, double lon, float &headingRadian)
|
||||
float estimatedHeading(double lat, double lon)
|
||||
{
|
||||
headingRadian = 0.0f;
|
||||
|
||||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING)
|
||||
return true;
|
||||
|
||||
if (!screen)
|
||||
return false;
|
||||
|
||||
if (screen->hasHeading()) {
|
||||
headingRadian = screen->getHeading() * DEG_TO_RAD;
|
||||
return true;
|
||||
}
|
||||
|
||||
const float estimatedHeadingDeg = screen->estimatedHeading(lat, lon);
|
||||
if (!(estimatedHeadingDeg >= 0.0f))
|
||||
return false;
|
||||
|
||||
headingRadian = estimatedHeadingDeg * DEG_TO_RAD;
|
||||
return true;
|
||||
}
|
||||
|
||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian)
|
||||
{
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
return bearingRadian - headingRadian;
|
||||
|
||||
return bearingRadian;
|
||||
}
|
||||
|
||||
float radiansToDegrees360(float angleRadian)
|
||||
{
|
||||
constexpr float fullTurnDeg = 360.0f;
|
||||
float degrees = angleRadian * RAD_TO_DEG;
|
||||
if (degrees < 0.0f)
|
||||
degrees += fullTurnDeg;
|
||||
else if (degrees >= fullTurnDeg)
|
||||
degrees -= fullTurnDeg;
|
||||
return degrees;
|
||||
// Simple magnetic declination estimation
|
||||
// This is a very basic implementation - the original might be more sophisticated
|
||||
return 0.0f; // Return 0 for now, indicating no heading available
|
||||
}
|
||||
|
||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
||||
@@ -177,4 +137,4 @@ uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
||||
|
||||
} // namespace CompassRenderer
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "graphics/Screen.h"
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||
#include <OLEDDisplay.h>
|
||||
#include <OLEDDisplayUi.h>
|
||||
|
||||
@@ -24,9 +25,7 @@ void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, u
|
||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing);
|
||||
|
||||
// Navigation and location functions
|
||||
bool getHeadingRadians(double lat, double lon, float &headingRadian);
|
||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian);
|
||||
float radiansToDegrees360(float angleRadian);
|
||||
float estimatedHeading(double lat, double lon);
|
||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
|
||||
|
||||
} // namespace CompassRenderer
|
||||
|
||||
@@ -408,16 +408,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
display->drawString(nameX, getTextPositions(display)[line++], device_role);
|
||||
|
||||
// === Third Row: Radio Preset ===
|
||||
// For custom modem settings show the actual parameters; for presets use the preset name.
|
||||
char modeStr[16];
|
||||
if (!config.lora.use_preset) {
|
||||
snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast<unsigned>(config.lora.bandwidth),
|
||||
static_cast<unsigned>(config.lora.spread_factor), static_cast<unsigned>(config.lora.coding_rate));
|
||||
} else {
|
||||
strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true),
|
||||
sizeof(modeStr) - 1);
|
||||
modeStr[sizeof(modeStr) - 1] = '\0';
|
||||
}
|
||||
auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
|
||||
|
||||
char regionradiopreset[25];
|
||||
const char *region = myRegion ? myRegion->name : NULL;
|
||||
@@ -425,7 +416,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region);
|
||||
} else {
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr);
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode);
|
||||
}
|
||||
}
|
||||
textWidth = display->getStringWidth(regionradiopreset);
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
#include "main.h"
|
||||
#include "mesh/Default.h"
|
||||
#include "mesh/MeshTypes.h"
|
||||
#include "mesh/RadioLibInterface.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
@@ -26,7 +25,6 @@
|
||||
#include "modules/TraceRouteModule.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
|
||||
@@ -161,22 +159,31 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: without a reboot, reconfigure() applies the region directly.
|
||||
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
|
||||
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora
|
||||
// rather than a hardcoded check for LORA_24.
|
||||
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
|
||||
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
|
||||
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection");
|
||||
return;
|
||||
}
|
||||
|
||||
config.lora.region = selectedRegion;
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
// FIXME: This should be a method consolidated with the same logic in the admin message as well
|
||||
// This is needed as we wait til picking the LoRa region to generate keys for the first time.
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
if (!owner.is_licensed) {
|
||||
bool keygenSuccess = false;
|
||||
if (config.security.private_key.size == 32) {
|
||||
// public key is derived from private, so this will always have the same result.
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
} else {
|
||||
LOG_INFO("Generate new PKI keys");
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
config.lora.tx_enabled = true;
|
||||
@@ -192,6 +199,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
}
|
||||
|
||||
service->reloadConfig(changes);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
});
|
||||
|
||||
bannerOptions.durationMs = duration;
|
||||
@@ -257,24 +265,13 @@ void menuHandler::FrequencySlotPicker()
|
||||
optionsEnumArray[options++] = 0;
|
||||
|
||||
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
|
||||
|
||||
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
|
||||
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
|
||||
: bwCodeToKHz(loraConfig.bandwidth);
|
||||
|
||||
uint32_t numChannels = 0;
|
||||
if (myRegion) {
|
||||
// Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round()
|
||||
const double spacing = myRegion->profile->spacing;
|
||||
const double padding = myRegion->profile->padding;
|
||||
const double channelBandwidthMHz = bw / 1000.0;
|
||||
const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing;
|
||||
const double denominator = spacing + (padding * 2) + channelBandwidthMHz;
|
||||
if (denominator > 0.0) {
|
||||
numChannels = static_cast<uint32_t>(round(numerator / denominator));
|
||||
} else {
|
||||
LOG_WARN("Invalid region configuration: non-positive channel spacing/width");
|
||||
}
|
||||
numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0)));
|
||||
} else {
|
||||
LOG_WARN("Region not set, cannot calculate number of channels");
|
||||
return;
|
||||
@@ -310,6 +307,7 @@ void menuHandler::FrequencySlotPicker()
|
||||
|
||||
config.lora.channel_num = selected;
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
};
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
@@ -348,6 +346,7 @@ void menuHandler::radioPresetPicker()
|
||||
config.lora.channel_num = 0; // Reset to default channel for the preset
|
||||
config.lora.override_frequency = 0; // Clear any custom frequency
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
});
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
#include "CompassRenderer.h"
|
||||
#include "NodeDB.h"
|
||||
#include "NodeListRenderer.h"
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
#include "modules/StatusMessageModule.h"
|
||||
#endif
|
||||
#include "UIRenderer.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "gps/RTC.h" // for getTime() function
|
||||
@@ -95,41 +92,8 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
||||
|
||||
// 1) Choose target candidate (long vs short) only if present
|
||||
const char *raw = nullptr;
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
// If long-name mode is enabled, and we have a recent status for this node,
|
||||
// prefer "(short_name) statusText" as the raw candidate.
|
||||
std::string composedFromStatus;
|
||||
if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) {
|
||||
const auto &recent = statusMessageModule->getRecentReceived();
|
||||
const StatusMessageModule::RecentStatus *found = nullptr;
|
||||
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
|
||||
if (it->fromNodeId == node->num && !it->statusText.empty()) {
|
||||
found = &(*it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
const char *shortName = node->user.short_name;
|
||||
composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size());
|
||||
composedFromStatus += "(";
|
||||
if (shortName && *shortName) {
|
||||
composedFromStatus += shortName;
|
||||
}
|
||||
composedFromStatus += ") ";
|
||||
composedFromStatus += found->statusText;
|
||||
|
||||
raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// If we didn't compose from status, use normal long/short selection
|
||||
if (!raw) {
|
||||
if (node && node->has_user) {
|
||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
||||
}
|
||||
if (node && node->has_user) {
|
||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
||||
}
|
||||
|
||||
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
|
||||
@@ -409,13 +373,14 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
}
|
||||
}
|
||||
|
||||
const char *distanceLabel = (strlen(distStr) > 0) ? distStr : "?";
|
||||
int offset = (currentResolution == ScreenResolution::High)
|
||||
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
||||
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
||||
int rightEdge = x + columnWidth - offset;
|
||||
int textWidth = display->getStringWidth(distanceLabel);
|
||||
display->drawString(rightEdge - textWidth, y, distanceLabel);
|
||||
if (strlen(distStr) > 0) {
|
||||
int offset = (currentResolution == ScreenResolution::High)
|
||||
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
||||
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
||||
int rightEdge = x + columnWidth - offset;
|
||||
int textWidth = display->getStringWidth(distStr);
|
||||
display->drawString(rightEdge - textWidth, y, distStr);
|
||||
}
|
||||
}
|
||||
|
||||
void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
||||
@@ -466,8 +431,8 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
}
|
||||
}
|
||||
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
||||
float myHeadingRadian, double userLat, double userLon)
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||
double userLat, double userLon)
|
||||
{
|
||||
if (!nodeDB->hasValidPosition(node))
|
||||
return;
|
||||
@@ -481,11 +446,11 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
double nodeLat = node->position.latitude_i * 1e-7;
|
||||
double nodeLon = node->position.longitude_i * 1e-7;
|
||||
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
|
||||
float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian);
|
||||
float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing);
|
||||
float bearingToNode = RAD_TO_DEG * bearing;
|
||||
float relativeBearing = fmod((bearingToNode - myHeading + 360), 360);
|
||||
// Shrink size by 2px
|
||||
int size = FONT_HEIGHT_SMALL - 5;
|
||||
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg);
|
||||
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearing);
|
||||
/*
|
||||
float angle = relativeBearing * DEG_TO_RAD;
|
||||
float halfSize = size / 2.0;
|
||||
@@ -515,27 +480,12 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
*/
|
||||
}
|
||||
|
||||
void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float, double,
|
||||
double)
|
||||
{
|
||||
if (!nodeDB->hasValidPosition(node))
|
||||
return;
|
||||
|
||||
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
||||
int arrowXOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18);
|
||||
int centerX = x + columnWidth - arrowXOffset;
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(centerX, y, "?");
|
||||
}
|
||||
|
||||
// =============================
|
||||
// Main Screen Functions
|
||||
// =============================
|
||||
|
||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon)
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon)
|
||||
{
|
||||
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
|
||||
const int rowYOffset = FONT_HEIGHT_SMALL - 3;
|
||||
@@ -620,7 +570,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
renderer(display, node, xPos, yPos, columnWidth);
|
||||
|
||||
if (extras)
|
||||
extras(display, node, xPos, yPos, columnWidth, headingRadian, lat, lon);
|
||||
extras(display, node, xPos, yPos, columnWidth, heading, lat, lon);
|
||||
|
||||
lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
|
||||
yOffset += rowYOffset;
|
||||
@@ -815,13 +765,9 @@ void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
#endif
|
||||
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
float headingRadian = 0.0f;
|
||||
float heading = 0;
|
||||
bool validHeading = false;
|
||||
auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (!ourNode || !nodeDB->hasValidPosition(ourNode)) {
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
double lat = DegD(ourNode->position.latitude_i);
|
||||
double lon = DegD(ourNode->position.longitude_i);
|
||||
|
||||
@@ -833,12 +779,21 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
|
||||
lastSwitchTime = now;
|
||||
}
|
||||
#endif
|
||||
if (!CompassRenderer::getHeadingRadians(lat, lon, headingRadian)) {
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, lat, lon);
|
||||
return;
|
||||
}
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) {
|
||||
#if HAS_GPS
|
||||
if (screen->hasHeading()) {
|
||||
heading = screen->getHeading(); // degrees
|
||||
validHeading = true;
|
||||
} else {
|
||||
heading = screen->estimatedHeading(lat, lon);
|
||||
validHeading = !isnan(heading);
|
||||
}
|
||||
#endif
|
||||
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon);
|
||||
if (!validHeading)
|
||||
return;
|
||||
}
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
|
||||
}
|
||||
|
||||
/// Draw a series of fields in a column, wrapping to multiple columns if needed
|
||||
|
||||
@@ -32,7 +32,7 @@ enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATIO
|
||||
|
||||
// Main node list screen function
|
||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float headingRadian = 0, double lat = 0,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0,
|
||||
double lon = 0);
|
||||
|
||||
// Entry renderers
|
||||
@@ -43,8 +43,8 @@ void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
||||
void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
|
||||
|
||||
// Extras renderers
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
||||
float myHeadingRadian, double userLat, double userLon);
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||
double userLat, double userLon);
|
||||
|
||||
// Screen frame functions
|
||||
void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
+103
-184
@@ -5,9 +5,6 @@
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "NodeListRenderer.h"
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
#include "modules/StatusMessageModule.h"
|
||||
#endif
|
||||
#include "UIRenderer.h"
|
||||
#include "airtime.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
@@ -41,15 +38,6 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
}
|
||||
}
|
||||
|
||||
static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_t compassY, const char *statusLine1,
|
||||
const char *statusLine2)
|
||||
{
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(compassX, compassY - FONT_HEIGHT_SMALL, statusLine1);
|
||||
display->drawString(compassX, compassY, statusLine2);
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
}
|
||||
|
||||
void graphics::UIRenderer::rebuildFavoritedNodes()
|
||||
{
|
||||
favoritedNodes.clear();
|
||||
@@ -302,7 +290,7 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
|
||||
// * Favorite Node Info *
|
||||
// **********************
|
||||
// cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library
|
||||
void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
if (favoritedNodes.empty())
|
||||
return;
|
||||
@@ -354,57 +342,6 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
|
||||
}
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
// === Optional: Last received StatusMessage line for this node ===
|
||||
// Display it directly under the username line (if we have one).
|
||||
if (statusMessageModule) {
|
||||
const auto &recent = statusMessageModule->getRecentReceived();
|
||||
const StatusMessageModule::RecentStatus *found = nullptr;
|
||||
|
||||
// Search newest-to-oldest
|
||||
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
|
||||
if (it->fromNodeId == node->num && !it->statusText.empty()) {
|
||||
found = &(*it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
std::string statusLine = std::string(" Status: ") + found->statusText;
|
||||
{
|
||||
const int screenW = display->getWidth();
|
||||
const int ellipseW = display->getStringWidth("...");
|
||||
int w = display->getStringWidth(statusLine.c_str());
|
||||
|
||||
// Only do work if it overflows
|
||||
if (w > screenW) {
|
||||
bool truncated = false;
|
||||
if (ellipseW > screenW) {
|
||||
statusLine.clear();
|
||||
} else {
|
||||
while (!statusLine.empty()) {
|
||||
// remove one char (byte) at a time
|
||||
statusLine.pop_back();
|
||||
truncated = true;
|
||||
|
||||
// Measure candidate with ellipsis appended
|
||||
std::string candidate = statusLine + "...";
|
||||
if (display->getStringWidth(candidate.c_str()) <= screenW) {
|
||||
statusLine = std::move(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (statusLine.empty() && ellipseW <= screenW) {
|
||||
statusLine = "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
display->drawString(x, getTextPositions(display)[line++], statusLine.c_str());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// === 2. Signal and Hops (combined on one line, if available) ===
|
||||
char signalHopsStr[32] = "";
|
||||
bool haveSignal = false;
|
||||
@@ -701,54 +638,51 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
display->drawString(x, getTextPositions(display)[line++], batLine);
|
||||
}
|
||||
|
||||
bool showCompass = false;
|
||||
float myHeading = 0.0f;
|
||||
float bearing = 0.0f;
|
||||
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
|
||||
const bool hasNodePositionFix = nodeDB->hasValidPosition(node);
|
||||
const char *statusLine1 = nullptr;
|
||||
const char *statusLine2 = nullptr;
|
||||
if (hasOwnPositionFix && hasNodePositionFix) {
|
||||
const auto &op = ourNode->position;
|
||||
showCompass = CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading);
|
||||
if (showCompass) {
|
||||
const auto &p = node->position;
|
||||
bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||||
bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading);
|
||||
} else {
|
||||
statusLine1 = "No";
|
||||
statusLine2 = "Heading";
|
||||
}
|
||||
} else if (!hasOwnPositionFix || !hasNodePositionFix) {
|
||||
statusLine1 = "No";
|
||||
statusLine2 = "Fix";
|
||||
}
|
||||
|
||||
// --- Compass Rendering: landscape (wide) screens use the original side-aligned logic ---
|
||||
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
||||
if (showCompass || statusLine1) {
|
||||
bool showCompass = false;
|
||||
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
||||
showCompass = true;
|
||||
}
|
||||
if (showCompass) {
|
||||
const int16_t topY = getTextPositions(display)[1];
|
||||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
|
||||
const int16_t usableHeight = bottomY - topY - 5;
|
||||
int16_t compassRadius = usableHeight / 2;
|
||||
if (compassRadius < 8)
|
||||
compassRadius = 8;
|
||||
const int16_t compassDiam = compassRadius * 2;
|
||||
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
||||
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
||||
const int16_t compassDiam = compassRadius * 2;
|
||||
|
||||
const auto &op = ourNode->position;
|
||||
float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
||||
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
||||
|
||||
const auto &p = node->position;
|
||||
/* unused
|
||||
float d =
|
||||
GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
||||
*/
|
||||
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) {
|
||||
myHeading = 0;
|
||||
} else {
|
||||
bearing -= myHeading;
|
||||
}
|
||||
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (showCompass) {
|
||||
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing);
|
||||
} else {
|
||||
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
|
||||
}
|
||||
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing);
|
||||
}
|
||||
// else show nothing
|
||||
} else {
|
||||
// Portrait or square: put compass at the bottom and centered, scaled to fit available space
|
||||
if (showCompass || statusLine1) {
|
||||
bool showCompass = false;
|
||||
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
||||
showCompass = true;
|
||||
}
|
||||
if (showCompass) {
|
||||
int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2)
|
||||
: getTextPositions(display)[1];
|
||||
const int margin = 4;
|
||||
@@ -759,8 +693,8 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
#else
|
||||
const int navBarHeight = 0;
|
||||
#endif
|
||||
// --------- END PATCH FOR EINK NAV BAR -----------
|
||||
int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin;
|
||||
// --------- END PATCH FOR EINK NAV BAR -----------
|
||||
|
||||
if (availableHeight < FONT_HEIGHT_SMALL * 2)
|
||||
return;
|
||||
@@ -774,13 +708,25 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
int compassX = x + SCREEN_WIDTH / 2;
|
||||
int compassY = yBelowContent + availableHeight / 2;
|
||||
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (showCompass) {
|
||||
graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||||
graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
|
||||
} else {
|
||||
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
|
||||
const auto &op = ourNode->position;
|
||||
float myHeading = 0;
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) {
|
||||
myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
||||
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
||||
}
|
||||
graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||||
|
||||
const auto &p = node->position;
|
||||
/* unused
|
||||
float d =
|
||||
GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
||||
*/
|
||||
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING)
|
||||
bearing -= myHeading;
|
||||
graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
|
||||
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
}
|
||||
// else show nothing
|
||||
}
|
||||
@@ -1216,7 +1162,6 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
const int *textPos = getTextPositions(display);
|
||||
|
||||
// === First Row: My Location ===
|
||||
#if HAS_GPS
|
||||
@@ -1231,12 +1176,12 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
} else {
|
||||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
||||
}
|
||||
drawSatelliteIcon(display, x, textPos[line]);
|
||||
drawSatelliteIcon(display, x, getTextPositions(display)[line]);
|
||||
int xOffset = (currentResolution == ScreenResolution::High) ? 6 : 0;
|
||||
display->drawString(x + 11 + xOffset, textPos[line++], displayLine);
|
||||
display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine);
|
||||
} else {
|
||||
// Onboard GPS
|
||||
UIRenderer::drawGps(display, 0, textPos[line++], gpsStatus);
|
||||
UIRenderer::drawGps(display, 0, getTextPositions(display)[line++], gpsStatus);
|
||||
}
|
||||
|
||||
config.display.heading_bold = origBold;
|
||||
@@ -1245,36 +1190,18 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
|
||||
int32_t(gpsStatus->getAltitude()));
|
||||
|
||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
|
||||
const bool hasLiveGpsFix =
|
||||
(gpsStatus && gpsStatus->getHasLock() && (gpsStatus->getLatitude() != 0 || gpsStatus->getLongitude() != 0));
|
||||
const bool hasSensorHeading = screen->hasHeading();
|
||||
float heading = 0.0f;
|
||||
// === Determine Compass Heading ===
|
||||
float heading = 0;
|
||||
bool validHeading = false;
|
||||
const char *statusLine1 = nullptr;
|
||||
const char *statusLine2 = nullptr;
|
||||
if (hasSensorHeading || hasLiveGpsFix || hasOwnPositionFix) {
|
||||
double headingLat = 0.0;
|
||||
double headingLon = 0.0;
|
||||
if (hasLiveGpsFix) {
|
||||
headingLat = DegD(gpsStatus->getLatitude());
|
||||
headingLon = DegD(gpsStatus->getLongitude());
|
||||
} else if (hasOwnPositionFix) {
|
||||
const auto &op = ourNode->position;
|
||||
headingLat = DegD(op.latitude_i);
|
||||
headingLon = DegD(op.longitude_i);
|
||||
}
|
||||
validHeading = CompassRenderer::getHeadingRadians(headingLat, headingLon, heading);
|
||||
}
|
||||
|
||||
if (!validHeading) {
|
||||
if (hasSensorHeading || hasLiveGpsFix || hasOwnPositionFix) {
|
||||
statusLine1 = "No";
|
||||
statusLine2 = "Heading";
|
||||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) {
|
||||
validHeading = true;
|
||||
} else {
|
||||
if (screen->hasHeading()) {
|
||||
heading = radians(screen->getHeading());
|
||||
validHeading = true;
|
||||
} else {
|
||||
statusLine1 = "No";
|
||||
statusLine2 = "Fix";
|
||||
heading = screen->estimatedHeading(geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7);
|
||||
validHeading = !isnan(heading);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,18 +1219,18 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true);
|
||||
#endif
|
||||
|
||||
display->drawString(0, textPos[line++], uptimeStr);
|
||||
display->drawString(0, getTextPositions(display)[line++], uptimeStr);
|
||||
} else {
|
||||
display->drawString(0, textPos[line++], "Last: ?");
|
||||
display->drawString(0, getTextPositions(display)[line++], "Last: ?");
|
||||
}
|
||||
|
||||
// === Third Row: Line 1 GPS Info ===
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1");
|
||||
UIRenderer::drawGpsCoordinates(display, x, getTextPositions(display)[line++], gpsStatus, "line1");
|
||||
|
||||
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC &&
|
||||
uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) {
|
||||
// === Fourth Row: Line 2 GPS Info ===
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2");
|
||||
UIRenderer::drawGpsCoordinates(display, x, getTextPositions(display)[line++], gpsStatus, "line2");
|
||||
}
|
||||
|
||||
// === Final Row: Altitude ===
|
||||
@@ -1314,14 +1241,14 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
} else {
|
||||
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0im", alt);
|
||||
}
|
||||
display->drawString(x, textPos[line++], altitudeLine);
|
||||
display->drawString(x, getTextPositions(display)[line++], altitudeLine);
|
||||
}
|
||||
#if !defined(M5STACK_UNITC6L)
|
||||
// === Draw Compass ===
|
||||
if (validHeading || statusLine1) {
|
||||
// === Draw Compass if heading is valid ===
|
||||
if (validHeading) {
|
||||
// --- Compass Rendering: landscape (wide) screens use original side-aligned logic ---
|
||||
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
||||
const int16_t topY = textPos[1];
|
||||
const int16_t topY = getTextPositions(display)[1];
|
||||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height
|
||||
const int16_t usableHeight = bottomY - topY - 5;
|
||||
|
||||
@@ -1334,33 +1261,29 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
// Center vertically and nudge down slightly to keep "N" clear of header
|
||||
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
||||
|
||||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading);
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (validHeading) {
|
||||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading);
|
||||
|
||||
// "N" label
|
||||
float northAngle = 0;
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
northAngle = -heading;
|
||||
float radius = compassRadius;
|
||||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||||
// "N" label
|
||||
float northAngle = 0;
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
northAngle = -heading;
|
||||
float radius = compassRadius;
|
||||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||||
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||||
display->setColor(WHITE);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||||
} else {
|
||||
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
|
||||
}
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||||
display->setColor(WHITE);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||||
} else {
|
||||
// Portrait or square: put compass at the bottom and centered, scaled to fit available space
|
||||
// For E-Ink screens, account for navigation bar at the bottom!
|
||||
int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2;
|
||||
int yBelowContent = getTextPositions(display)[5] + FONT_HEIGHT_SMALL + 2;
|
||||
const int margin = 4;
|
||||
int availableHeight =
|
||||
#if defined(USE_EINK)
|
||||
@@ -1381,29 +1304,25 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
int compassX = x + SCREEN_WIDTH / 2;
|
||||
int compassY = yBelowContent + availableHeight / 2;
|
||||
|
||||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading);
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (validHeading) {
|
||||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading);
|
||||
|
||||
// "N" label
|
||||
float northAngle = 0;
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
northAngle = -heading;
|
||||
float radius = compassRadius;
|
||||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||||
// "N" label
|
||||
float northAngle = 0;
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
northAngle = -heading;
|
||||
float radius = compassRadius;
|
||||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||||
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||||
display->setColor(WHITE);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||||
} else {
|
||||
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
|
||||
}
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||||
display->setColor(WHITE);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -50,7 +50,7 @@ class UIRenderer
|
||||
// Navigation bar overlay
|
||||
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
|
||||
static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
|
||||
@@ -177,8 +177,24 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
if (!owner.is_licensed) {
|
||||
bool keygenSuccess = false;
|
||||
|
||||
if (config.security.private_key.size == 32) {
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
} else {
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
+129
@@ -340,9 +340,138 @@ void setup()
|
||||
|
||||
#ifdef BLE_LED
|
||||
pinMode(BLE_LED, OUTPUT);
|
||||
#ifdef BLE_LED_INVERTED
|
||||
digitalWrite(BLE_LED, HIGH);
|
||||
#else
|
||||
digitalWrite(BLE_LED, LOW);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(T_DECK)
|
||||
// GPIO10 manages all peripheral power supplies
|
||||
// Turn on peripheral power immediately after MUC starts.
|
||||
// If some boards are turned on late, ESP32 will reset due to low voltage.
|
||||
// ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) ,
|
||||
// TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder)
|
||||
pinMode(KB_POWERON, OUTPUT);
|
||||
digitalWrite(KB_POWERON, HIGH);
|
||||
// T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus
|
||||
// We need to initialize all CS pins in advance otherwise there will be SPI communication issues
|
||||
// e.g. when detecting the SD card
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(TFT_CS, OUTPUT);
|
||||
digitalWrite(TFT_CS, HIGH);
|
||||
delay(100);
|
||||
#elif defined(T_DECK_PRO)
|
||||
pinMode(LORA_EN, OUTPUT);
|
||||
digitalWrite(LORA_EN, HIGH);
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(PIN_EINK_CS, OUTPUT);
|
||||
digitalWrite(PIN_EINK_CS, HIGH);
|
||||
#if PIN_EINK_RES >= 0
|
||||
pinMode(PIN_EINK_RES, OUTPUT);
|
||||
digitalWrite(PIN_EINK_RES, HIGH);
|
||||
#endif
|
||||
pinMode(CST328_PIN_RST, OUTPUT);
|
||||
digitalWrite(CST328_PIN_RST, HIGH);
|
||||
#elif defined(T_LORA_PAGER)
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(TFT_CS, OUTPUT);
|
||||
digitalWrite(TFT_CS, HIGH);
|
||||
pinMode(KB_INT, INPUT_PULLUP);
|
||||
// io expander
|
||||
io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL);
|
||||
io.pinMode(EXPANDS_DRV_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_DRV_EN, HIGH);
|
||||
io.pinMode(EXPANDS_AMP_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_AMP_EN, LOW);
|
||||
io.pinMode(EXPANDS_LORA_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_LORA_EN, HIGH);
|
||||
io.pinMode(EXPANDS_GPS_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_GPS_EN, HIGH);
|
||||
io.pinMode(EXPANDS_KB_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_KB_EN, HIGH);
|
||||
io.pinMode(EXPANDS_SD_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_SD_EN, HIGH);
|
||||
io.pinMode(EXPANDS_GPIO_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_GPIO_EN, HIGH);
|
||||
io.pinMode(EXPANDS_SD_PULLEN, INPUT);
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
pinMode(KB_INT, INPUT);
|
||||
digitalWrite(BLE_LED, LED_STATE_OFF);
|
||||
#endif
|
||||
|
||||
#if defined(T_DECK)
|
||||
// GPIO10 manages all peripheral power supplies
|
||||
// Turn on peripheral power immediately after MUC starts.
|
||||
// If some boards are turned on late, ESP32 will reset due to low voltage.
|
||||
// ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) ,
|
||||
// TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder)
|
||||
pinMode(KB_POWERON, OUTPUT);
|
||||
digitalWrite(KB_POWERON, HIGH);
|
||||
// T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus
|
||||
// We need to initialize all CS pins in advance otherwise there will be SPI communication issues
|
||||
// e.g. when detecting the SD card
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(TFT_CS, OUTPUT);
|
||||
digitalWrite(TFT_CS, HIGH);
|
||||
delay(100);
|
||||
#elif defined(T_DECK_PRO)
|
||||
pinMode(LORA_EN, OUTPUT);
|
||||
digitalWrite(LORA_EN, HIGH);
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(PIN_EINK_CS, OUTPUT);
|
||||
digitalWrite(PIN_EINK_CS, HIGH);
|
||||
#if PIN_EINK_RES >= 0
|
||||
pinMode(PIN_EINK_RES, OUTPUT);
|
||||
digitalWrite(PIN_EINK_RES, HIGH);
|
||||
#endif
|
||||
pinMode(CST328_PIN_RST, OUTPUT);
|
||||
digitalWrite(CST328_PIN_RST, HIGH);
|
||||
#elif defined(T_LORA_PAGER)
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(TFT_CS, OUTPUT);
|
||||
digitalWrite(TFT_CS, HIGH);
|
||||
pinMode(KB_INT, INPUT_PULLUP);
|
||||
// io expander
|
||||
io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL);
|
||||
io.pinMode(EXPANDS_DRV_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_DRV_EN, HIGH);
|
||||
io.pinMode(EXPANDS_AMP_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_AMP_EN, LOW);
|
||||
io.pinMode(EXPANDS_LORA_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_LORA_EN, HIGH);
|
||||
io.pinMode(EXPANDS_GPS_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_GPS_EN, HIGH);
|
||||
io.pinMode(EXPANDS_KB_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_KB_EN, HIGH);
|
||||
io.pinMode(EXPANDS_SD_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_SD_EN, HIGH);
|
||||
io.pinMode(EXPANDS_GPIO_EN, OUTPUT);
|
||||
io.digitalWrite(EXPANDS_GPIO_EN, HIGH);
|
||||
io.pinMode(EXPANDS_SD_PULLEN, INPUT);
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
pinMode(KB_INT, INPUT);
|
||||
#endif
|
||||
|
||||
concurrency::hasBeenSetup = true;
|
||||
#if HAS_SCREEN
|
||||
meshtastic_Config_DisplayConfig_OledType screen_model =
|
||||
|
||||
+5
-23
@@ -10,26 +10,8 @@
|
||||
#include "memGet.h"
|
||||
#include "configuration.h"
|
||||
|
||||
#if defined(MESHTASTIC_DYNAMIC_SBRK_HEAP)
|
||||
#include <malloc.h>
|
||||
#include <unistd.h> // sbrk
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
// Returns the uncommitted sbrk headroom: addressable space between the current heap
|
||||
// break and the stack pointer that has not yet been committed to the arena.
|
||||
static uint32_t sbrkHeadroom()
|
||||
{
|
||||
// defined in STM32 linker script
|
||||
extern char _estack;
|
||||
extern char _Min_Stack_Size;
|
||||
|
||||
uint32_t max_sp = (uint32_t)(&_estack - &_Min_Stack_Size);
|
||||
uint32_t heap_end = (uint32_t)sbrk(0);
|
||||
return (max_sp > heap_end) ? (max_sp - heap_end) : 0;
|
||||
}
|
||||
#else
|
||||
#error Unsupported architecture!
|
||||
#endif
|
||||
#include <malloc.h>
|
||||
#endif
|
||||
|
||||
MemGet memGet;
|
||||
@@ -46,9 +28,9 @@ uint32_t MemGet::getFreeHeap()
|
||||
return dbgHeapFree();
|
||||
#elif defined(ARCH_RP2040)
|
||||
return rp2040.getFreeHeap();
|
||||
#elif defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) // Currently: ARCH_STM32WL
|
||||
#elif defined(ARCH_STM32WL)
|
||||
struct mallinfo m = mallinfo();
|
||||
return m.fordblks + sbrkHeadroom(); // Free space within arena + uncommitted sbrk headroom
|
||||
return m.fordblks; // Total free space (bytes)
|
||||
#else
|
||||
// this platform does not have heap management function implemented
|
||||
return UINT32_MAX;
|
||||
@@ -67,9 +49,9 @@ uint32_t MemGet::getHeapSize()
|
||||
return dbgHeapTotal();
|
||||
#elif defined(ARCH_RP2040)
|
||||
return rp2040.getTotalHeap();
|
||||
#elif defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) // Currently: ARCH_STM32WL
|
||||
#elif defined(ARCH_STM32WL)
|
||||
struct mallinfo m = mallinfo();
|
||||
return m.arena + sbrkHeadroom(); // Non-mmapped space allocated + uncommitted sbrk headroom
|
||||
return m.arena; // Non-mmapped space allocated (bytes)
|
||||
#else
|
||||
// this platform does not have heap management function implemented
|
||||
return UINT32_MAX;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <memory>
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
#include "HardwareRNG.h"
|
||||
#include "NodeDB.h"
|
||||
#include "aes-ccm.h"
|
||||
#include "meshUtils.h"
|
||||
@@ -27,15 +26,6 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey)
|
||||
{
|
||||
// Mix in any randomness we can, to make key generation stronger.
|
||||
CryptRNG.begin(optstr(APP_VERSION));
|
||||
|
||||
uint8_t hardwareEntropy[64] = {0};
|
||||
if (HardwareRNG::fill(hardwareEntropy, sizeof(hardwareEntropy), true)) {
|
||||
CryptRNG.stir(hardwareEntropy, sizeof(hardwareEntropy));
|
||||
} else {
|
||||
LOG_WARN("Hardware entropy unavailable, falling back to software RNG");
|
||||
}
|
||||
memset(hardwareEntropy, 0, sizeof(hardwareEntropy));
|
||||
|
||||
if (myNodeInfo.device_id.size == 16) {
|
||||
CryptRNG.stir(myNodeInfo.device_id.bytes, myNodeInfo.device_id.size);
|
||||
}
|
||||
@@ -71,33 +61,6 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user)
|
||||
{
|
||||
if (user.is_licensed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool keygenSuccess = false;
|
||||
if (security.private_key.size == 32) {
|
||||
if (regeneratePublicKey(security.public_key.bytes, security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
} else {
|
||||
LOG_INFO("Generate new PKI keys");
|
||||
generateKeyPair(security.public_key.bytes, security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
if (keygenSuccess) {
|
||||
security.public_key.size = 32;
|
||||
security.private_key.size = 32;
|
||||
user.public_key.size = 32;
|
||||
memcpy(user.public_key.bytes, security.public_key.bytes, 32);
|
||||
}
|
||||
|
||||
return keygenSuccess;
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,6 @@ class CryptoEngine
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
|
||||
virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user);
|
||||
|
||||
#endif
|
||||
void setDHPrivateKey(uint8_t *_private_key);
|
||||
|
||||
@@ -30,11 +30,6 @@
|
||||
#define min_node_info_broadcast_secs 60 * 60 // No regular broadcasts of more than once an hour
|
||||
#define min_neighbor_info_broadcast_secs 4 * 60 * 60
|
||||
#define default_map_publish_interval_secs 60 * 60
|
||||
|
||||
// Traffic management defaults
|
||||
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
|
||||
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
|
||||
|
||||
#ifdef USERPREFS_RINGTONE_NAG_SECS
|
||||
#define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS
|
||||
#else
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
#include "HardwareRNG.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <random>
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#if HAS_RADIO
|
||||
#include "RadioLibInterface.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_NRF52)
|
||||
#include <Adafruit_nRFCrypto.h>
|
||||
extern Adafruit_nRFCrypto nRFCrypto;
|
||||
#elif defined(ARCH_ESP32)
|
||||
#include <esp_system.h>
|
||||
#elif defined(ARCH_RP2040)
|
||||
#include <Arduino.h>
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
#include <random>
|
||||
#include <sys/random.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace HardwareRNG
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
void fillWithRandomDevice(uint8_t *buffer, size_t length)
|
||||
{
|
||||
std::random_device rd;
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
uint32_t value = rd();
|
||||
size_t toCopy = std::min(length - offset, sizeof(value));
|
||||
memcpy(buffer + offset, &value, toCopy);
|
||||
offset += toCopy;
|
||||
}
|
||||
}
|
||||
|
||||
#if HAS_RADIO
|
||||
bool mixWithLoRaEntropy(uint8_t *buffer, size_t length)
|
||||
{
|
||||
// Only attempt to pull entropy from the modem if it is initialized and exposes the helper.
|
||||
// When the radio stack is disabled or has not yet been configured, we simply skip this step
|
||||
// and return false so callers know no extra mixing occurred.
|
||||
RadioLibInterface *radio = RadioLibInterface::instance;
|
||||
if (!radio) {
|
||||
// Intentionally silent: this path runs during portduinoSetup() before the
|
||||
// console/SerialConsole is initialized, so LOG_* here would dereference a null pointer.
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr size_t chunkSize = 16;
|
||||
uint8_t scratch[chunkSize];
|
||||
size_t offset = 0;
|
||||
bool mixed = false;
|
||||
|
||||
while (offset < length) {
|
||||
size_t toCopy = std::min(length - offset, chunkSize);
|
||||
|
||||
// randomBytes() returns false if the modem does not support it or is not ready
|
||||
// (for instance, when the radio is powered down). We break immediately to avoid
|
||||
// blocking or returning partially-filled entropy and simply report failure.
|
||||
if (!radio->randomBytes(scratch, toCopy)) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < toCopy; ++i) {
|
||||
buffer[offset + i] ^= scratch[i];
|
||||
}
|
||||
|
||||
mixed = true;
|
||||
offset += toCopy;
|
||||
}
|
||||
|
||||
// Avoid leaving the modem-sourced bytes sitting on the stack longer than needed.
|
||||
if (mixed) {
|
||||
memset(scratch, 0, sizeof(scratch));
|
||||
}
|
||||
|
||||
return mixed;
|
||||
}
|
||||
#endif
|
||||
} // namespace
|
||||
|
||||
bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy)
|
||||
{
|
||||
if (!buffer || length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool filled = false;
|
||||
|
||||
#if defined(ARCH_NRF52)
|
||||
// The Nordic SDK RNG provides cryptographic-quality randomness backed by hardware.
|
||||
nRFCrypto.begin();
|
||||
auto result = nRFCrypto.Random.generate(buffer, length);
|
||||
nRFCrypto.end();
|
||||
filled = result;
|
||||
#elif defined(ARCH_ESP32)
|
||||
// ESP32 exposes a true RNG via esp_fill_random().
|
||||
esp_fill_random(buffer, length);
|
||||
filled = true;
|
||||
#elif defined(ARCH_RP2040)
|
||||
// RP2040 has a hardware random number generator accessible through the Arduino core.
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
uint32_t value = rp2040.hwrand32();
|
||||
size_t toCopy = std::min(length - offset, sizeof(value));
|
||||
memcpy(buffer + offset, &value, toCopy);
|
||||
offset += toCopy;
|
||||
}
|
||||
filled = true;
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
// Prefer the host OS RNG first when running under Portduino.
|
||||
ssize_t generated = ::getrandom(buffer, length, 0);
|
||||
if (generated == static_cast<ssize_t>(length)) {
|
||||
filled = true;
|
||||
}
|
||||
|
||||
if (!filled) {
|
||||
fillWithRandomDevice(buffer, length);
|
||||
filled = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!filled) {
|
||||
// As a last resort, fall back to std::random_device. This should only be reached
|
||||
// if a platform-specific source was unavailable.
|
||||
fillWithRandomDevice(buffer, length);
|
||||
filled = true;
|
||||
}
|
||||
|
||||
#if HAS_RADIO
|
||||
if (useRadioEntropy) {
|
||||
// Best-effort: if the radio is active and can provide modem entropy, XOR it over the
|
||||
// buffer to improve overall quality. We consider the filling a success if either a
|
||||
// good platform RNG or the modem RNG provided data, so we return true as long as at
|
||||
// least one of those steps succeeded.
|
||||
filled = mixWithLoRaEntropy(buffer, length) || filled;
|
||||
}
|
||||
#endif
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
bool seed(uint32_t &seedOut)
|
||||
{
|
||||
uint32_t candidate = 0;
|
||||
if (!fill(reinterpret_cast<uint8_t *>(&candidate), sizeof(candidate), true)) {
|
||||
return false;
|
||||
}
|
||||
seedOut = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace HardwareRNG
|
||||
@@ -1,28 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace HardwareRNG
|
||||
{
|
||||
|
||||
/**
|
||||
* Fill the provided buffer with random bytes sourced from the most
|
||||
* appropriate hardware-backed RNG available on the current platform.
|
||||
*
|
||||
* @param buffer Destination buffer for random bytes
|
||||
* @param length Number of bytes to write
|
||||
* @param useRadioEntropy If true, attempt to mix radio entropy into the output as well.
|
||||
* @return true if the buffer was fully populated with entropy, false on failure
|
||||
*/
|
||||
bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy = false);
|
||||
|
||||
/**
|
||||
* Populate a 32-bit seed value with hardware-backed randomness where possible.
|
||||
*
|
||||
* @param seedOut Destination for the generated seed value
|
||||
* @return true if a seed was produced from a reliable entropy source
|
||||
*/
|
||||
bool seed(uint32_t &seedOut);
|
||||
|
||||
} // namespace HardwareRNG
|
||||
@@ -71,10 +71,12 @@ template <typename T> bool LR11x0Interface<T>::init()
|
||||
|
||||
RadioLibInterface::init();
|
||||
|
||||
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range
|
||||
limitPower(LR1120_MAX_POWER);
|
||||
} else {
|
||||
limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range
|
||||
limitPower(LR1110_MAX_POWER);
|
||||
|
||||
if ((power > LR1120_MAX_POWER) &&
|
||||
(config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24)) { // clamp again if wide freq range
|
||||
power = LR1120_MAX_POWER;
|
||||
preambleLength = 12; // 12 is the default for operation above 2GHz
|
||||
}
|
||||
|
||||
#ifdef LR11X0_RF_SWITCH_SUBGHZ
|
||||
@@ -175,12 +177,6 @@ template <typename T> bool LR11x0Interface<T>::reconfigure()
|
||||
err = lora.setSyncWord(syncWord);
|
||||
assert(err == RADIOLIB_ERR_NONE);
|
||||
|
||||
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range
|
||||
limitPower(LR1120_MAX_POWER);
|
||||
} else {
|
||||
limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range
|
||||
}
|
||||
|
||||
err = lora.setPreambleLength(preambleLength);
|
||||
assert(err == RADIOLIB_ERR_NONE);
|
||||
|
||||
@@ -188,14 +184,14 @@ template <typename T> bool LR11x0Interface<T>::reconfigure()
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
|
||||
if (power > LR1110_MAX_POWER) // This chip has lower power limits than some
|
||||
power = LR1110_MAX_POWER;
|
||||
if ((power > LR1120_MAX_POWER) && (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24)) // 2.4G power limit
|
||||
power = LR1120_MAX_POWER;
|
||||
|
||||
err = lora.setOutputPower(power);
|
||||
assert(err == RADIOLIB_ERR_NONE);
|
||||
|
||||
// Apply RX gain mode — valid in STDBY, matches resetAGC() pattern
|
||||
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err);
|
||||
|
||||
startReceive(); // restart receiving
|
||||
|
||||
return RADIOLIB_ERR_NONE;
|
||||
|
||||
+5
-44
@@ -4,54 +4,19 @@
|
||||
#include "MeshTypes.h"
|
||||
#include "PointerQueue.h"
|
||||
#include "configuration.h"
|
||||
#include "detect/LoRaRadioType.h"
|
||||
|
||||
// Sentinel marking the end of a modem preset array
|
||||
static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END =
|
||||
static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(0xFF);
|
||||
|
||||
// Region profile: bundles the preset list with regulatory parameters shared across regions
|
||||
struct RegionProfile {
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default
|
||||
float spacing; // gaps between radio channels
|
||||
float padding; // padding at each side of the "operating channel"
|
||||
bool audioPermitted;
|
||||
bool licensedOnly; // a region profile for licensed operators only
|
||||
int8_t textThrottle; // throttle for text - future expansion
|
||||
int8_t positionThrottle; // throttle for location data - future expansion
|
||||
int8_t telemetryThrottle; // throttle for telemetry - future expansion
|
||||
uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place
|
||||
};
|
||||
|
||||
extern const RegionProfile PROFILE_STD;
|
||||
extern const RegionProfile PROFILE_EU868;
|
||||
extern const RegionProfile PROFILE_UNDEF;
|
||||
// extern const RegionProfile PROFILE_LITE;
|
||||
// extern const RegionProfile PROFILE_NARROW;
|
||||
// extern const RegionProfile PROFILE_HAM;
|
||||
|
||||
// Map from old region names to new region enums
|
||||
struct RegionInfo {
|
||||
meshtastic_Config_LoRaConfig_RegionCode code;
|
||||
float freqStart;
|
||||
float freqEnd;
|
||||
float dutyCycle; // modified by getEffectiveDutyCycle
|
||||
float dutyCycle;
|
||||
float spacing;
|
||||
uint8_t powerLimit; // Or zero for not set
|
||||
bool audioPermitted;
|
||||
bool freqSwitching;
|
||||
bool wideLora;
|
||||
const RegionProfile *profile;
|
||||
const char *name; // EU433 etc
|
||||
|
||||
// Preset accessors (delegate through profile)
|
||||
meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; }
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; }
|
||||
size_t getNumPresets() const
|
||||
{
|
||||
size_t n = 0;
|
||||
while (profile->presets[n] != MODEM_PRESET_END)
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
};
|
||||
|
||||
extern const RegionInfo regions[];
|
||||
@@ -60,7 +25,7 @@ extern const RegionInfo *myRegion;
|
||||
extern void initRegion();
|
||||
|
||||
// Valid LoRa spread factor range and defaults
|
||||
constexpr uint8_t LORA_SF_MIN = 5;
|
||||
constexpr uint8_t LORA_SF_MIN = 7;
|
||||
constexpr uint8_t LORA_SF_MAX = 12;
|
||||
constexpr uint8_t LORA_SF_DEFAULT = 11; // LONG_FAST default
|
||||
|
||||
@@ -72,14 +37,10 @@ constexpr uint8_t LORA_CR_DEFAULT = 5; // LONG_FAST default
|
||||
// Default bandwidth in kHz (LONG_FAST)
|
||||
constexpr float LORA_BW_DEFAULT_KHZ = 250.0f;
|
||||
|
||||
/// Clamp spread factor to the valid LoRa range [5, 12].
|
||||
/// Clamp spread factor to the valid LoRa range [7, 12].
|
||||
/// Out-of-range values (including 0 from unset preset mode) return LORA_SF_DEFAULT.
|
||||
static inline uint8_t clampSpreadFactor(uint8_t sf)
|
||||
{
|
||||
// We check for RF95 radios that are incompatible with Spreading Factors 5 and 6.
|
||||
if (radioType == RF95_RADIO && (sf == 5 || sf == 6))
|
||||
return LORA_SF_DEFAULT;
|
||||
|
||||
if (sf < LORA_SF_MIN || sf > LORA_SF_MAX)
|
||||
return LORA_SF_DEFAULT;
|
||||
return sf;
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
|
||||
#include "modules/TraceRouteModule.h"
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#include "NodeDB.h"
|
||||
|
||||
NextHopRouter::NextHopRouter() {}
|
||||
@@ -129,28 +126,15 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
|
||||
/* Check if we should be rebroadcasting this packet if so, do so. */
|
||||
bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
|
||||
{
|
||||
// Check if traffic management wants to exhaust this packet's hops
|
||||
bool exhaustHops = false;
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
if (trafficManagementModule && trafficManagementModule->shouldExhaustHops(*p)) {
|
||||
exhaustHops = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay)
|
||||
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
|
||||
if (!isToUs(p) && !isFromUs(p) && p->hop_limit > 0) {
|
||||
if (p->id != 0) {
|
||||
if (isRebroadcaster()) {
|
||||
if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
|
||||
meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
|
||||
LOG_INFO("Rebroadcast received message coming from %x", p->relay_node);
|
||||
|
||||
// If exhausting hops, force hop_limit = 0 regardless of other logic
|
||||
if (exhaustHops) {
|
||||
tosend->hop_limit = 0;
|
||||
LOG_INFO("Traffic management: exhausting hops for 0x%08x, setting hop_limit=0", getFrom(p));
|
||||
} else if (shouldDecrementHopLimit(p)) {
|
||||
// Use shared logic to determine if hop_limit should be decremented
|
||||
// Use shared logic to determine if hop_limit should be decremented
|
||||
if (shouldDecrementHopLimit(p)) {
|
||||
tosend->hop_limit--; // bump down the hop count
|
||||
} else {
|
||||
LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: preserving hop_limit");
|
||||
|
||||
+1
-36
@@ -1306,7 +1306,7 @@ void NodeDB::loadFromDisk()
|
||||
// Coerce LoRa config fields derived from presets while bootstrapping.
|
||||
// Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
|
||||
if (config.has_lora && config.lora.use_preset) {
|
||||
RadioInterface::clampConfigLora(config.lora);
|
||||
RadioInterface::bootstrapLoRaConfigFromPreset(config.lora);
|
||||
}
|
||||
|
||||
#if defined(USERPREFS_LORA_TX_DISABLED) && USERPREFS_LORA_TX_DISABLED
|
||||
@@ -1657,25 +1657,6 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p)
|
||||
return delta;
|
||||
}
|
||||
|
||||
HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p)
|
||||
{
|
||||
// Guard against invalid values.
|
||||
if (p.hop_start < p.hop_limit)
|
||||
return HopStartStatus::INVALID;
|
||||
|
||||
if (p.hop_start == 0) {
|
||||
// Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a
|
||||
// bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware
|
||||
// version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as
|
||||
// the bitfield is encrypted under the channel encryption key.
|
||||
if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield)
|
||||
return HopStartStatus::VALID;
|
||||
return HopStartStatus::MISSING_OR_UNKNOWN;
|
||||
}
|
||||
|
||||
return HopStartStatus::VALID;
|
||||
}
|
||||
|
||||
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown)
|
||||
{
|
||||
// Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a
|
||||
@@ -1713,22 +1694,6 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
|
||||
#include "MeshModule.h"
|
||||
#include "Throttle.h"
|
||||
|
||||
static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000;
|
||||
|
||||
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
|
||||
{
|
||||
static uint32_t lastLogMs = 0;
|
||||
if (Throttle::isWithinTimespanMs(lastLogMs, HOPSTART_DROP_LOG_INTERVAL_MS)) {
|
||||
return;
|
||||
}
|
||||
lastLogMs = millis();
|
||||
const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag);
|
||||
const bool hasBitfield = decoded && p.decoded.has_bitfield;
|
||||
LOG_DEBUG(
|
||||
"Drop packet (%s): hop_start invalid/missing (from=0x%x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)",
|
||||
context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield);
|
||||
}
|
||||
|
||||
/** Update position info for this node based on received position data
|
||||
*/
|
||||
void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src)
|
||||
|
||||
@@ -114,27 +114,6 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p);
|
||||
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
|
||||
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
|
||||
|
||||
enum class HopStartStatus : uint8_t { VALID = 0, MISSING_OR_UNKNOWN, INVALID };
|
||||
|
||||
/// Classify hop_start validity for forwarding decisions.
|
||||
HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p);
|
||||
|
||||
inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p)
|
||||
{
|
||||
#if !MESHTASTIC_PREHOP_DROP
|
||||
(void)p;
|
||||
return false;
|
||||
#else
|
||||
if (isFromUs(&p)) {
|
||||
return false; // local-originated packets should never be dropped by pre-hop drop policy
|
||||
}
|
||||
return classifyHopStart(p) != HopStartStatus::VALID;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped.
|
||||
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context);
|
||||
|
||||
enum LoadFileResult {
|
||||
// Successfully opened the file
|
||||
LOAD_SUCCESS = 1,
|
||||
|
||||
@@ -465,11 +465,6 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
|
||||
fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter;
|
||||
break;
|
||||
case meshtastic_ModuleConfig_traffic_management_tag:
|
||||
LOG_DEBUG("Send module config: traffic management");
|
||||
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
|
||||
fromRadioScratch.moduleConfig.payload_variant.traffic_management = moduleConfig.traffic_management;
|
||||
break;
|
||||
default:
|
||||
LOG_ERROR("Unknown module config type %d", config_state);
|
||||
}
|
||||
|
||||
@@ -17,4 +17,14 @@ template <class T> class PointerQueue : public TypedQueue<T *>
|
||||
|
||||
return this->dequeue(&p, maxWait) ? p : nullptr;
|
||||
}
|
||||
|
||||
#ifdef HAS_FREE_RTOS
|
||||
// returns a ptr or null if the queue was empty
|
||||
T *dequeuePtrFromISR(BaseType_t *higherPriWoken)
|
||||
{
|
||||
T *p;
|
||||
|
||||
return this->dequeueFromISR(&p, higherPriWoken) ? p : nullptr;
|
||||
}
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -240,7 +240,8 @@ bool RF95Interface::reconfigure()
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
|
||||
limitPower(RF95_MAX_POWER);
|
||||
if (power > RF95_MAX_POWER) // This chip has lower power limits than some
|
||||
power = RF95_MAX_POWER;
|
||||
|
||||
#ifdef USE_RF95_RFO
|
||||
err = lora->setOutputPower(power, true);
|
||||
|
||||
+132
-312
@@ -21,7 +21,6 @@
|
||||
#include <assert.h>
|
||||
#include <pb_decode.h>
|
||||
#include <pb_encode.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
@@ -33,32 +32,10 @@
|
||||
#include "STM32WLE5JCInterface.h"
|
||||
#endif
|
||||
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = {
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END};
|
||||
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = {
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END};
|
||||
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
|
||||
MODEM_PRESET_END};
|
||||
|
||||
// Region profiles: bundle preset list + regulatory parameters shared across regions
|
||||
// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot
|
||||
const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0};
|
||||
const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0};
|
||||
const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0};
|
||||
|
||||
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \
|
||||
#define RDEF(name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, frequency_switching, wide_lora) \
|
||||
{ \
|
||||
meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \
|
||||
wide_lora, &profile_ptr, #name \
|
||||
meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, \
|
||||
frequency_switching, wide_lora, #name \
|
||||
}
|
||||
|
||||
const RegionInfo regions[] = {
|
||||
@@ -66,7 +43,7 @@ const RegionInfo regions[] = {
|
||||
https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
*/
|
||||
RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD),
|
||||
RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false),
|
||||
|
||||
/*
|
||||
EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21]
|
||||
@@ -74,7 +51,8 @@ const RegionInfo regions[] = {
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf
|
||||
FIXME: https://github.com/meshtastic/firmware/issues/3371
|
||||
*/
|
||||
RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD),
|
||||
RDEF(EU_433, 433.0f, 434.0f, 10, 0, 10, true, false, false),
|
||||
|
||||
/*
|
||||
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
@@ -89,33 +67,33 @@ const RegionInfo regions[] = {
|
||||
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
|
||||
*/
|
||||
RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868),
|
||||
RDEF(EU_868, 869.4f, 869.65f, 10, 0, 27, false, false, false),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
*/
|
||||
RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD),
|
||||
RDEF(CN, 470.0f, 510.0f, 100, 0, 19, true, false, false),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf
|
||||
https://qiita.com/ammo0613/items/d952154f1195b64dc29f
|
||||
*/
|
||||
RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD),
|
||||
RDEF(JP, 920.5f, 923.5f, 100, 0, 13, true, false, false),
|
||||
|
||||
/*
|
||||
https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf
|
||||
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
|
||||
Also used in Brazil.
|
||||
*/
|
||||
RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD),
|
||||
RDEF(ANZ, 915.0f, 928.0f, 100, 0, 30, true, false, false),
|
||||
|
||||
/*
|
||||
433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions
|
||||
AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence
|
||||
NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100
|
||||
*/
|
||||
RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD),
|
||||
RDEF(ANZ_433, 433.05f, 434.79f, 100, 0, 14, true, false, false),
|
||||
|
||||
/*
|
||||
https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf
|
||||
@@ -123,13 +101,13 @@ const RegionInfo regions[] = {
|
||||
Note:
|
||||
- We do LBT, so 100% is allowed.
|
||||
*/
|
||||
RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD),
|
||||
RDEF(RU, 868.7f, 869.2f, 100, 0, 20, true, false, false),
|
||||
|
||||
/*
|
||||
https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0
|
||||
https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters
|
||||
*/
|
||||
RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD),
|
||||
RDEF(KR, 920.0f, 923.0f, 100, 0, 23, true, false, false),
|
||||
|
||||
/*
|
||||
Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor.
|
||||
@@ -137,40 +115,44 @@ const RegionInfo regions[] = {
|
||||
https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF
|
||||
https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283
|
||||
*/
|
||||
RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD),
|
||||
RDEF(TW, 920.0f, 925.0f, 100, 0, 27, true, false, false),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
*/
|
||||
RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD),
|
||||
RDEF(IN, 865.0f, 867.0f, 100, 0, 30, true, false, false),
|
||||
|
||||
/*
|
||||
https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752
|
||||
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
|
||||
*/
|
||||
RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD),
|
||||
RDEF(NZ_865, 864.0f, 868.0f, 100, 0, 36, true, false, false),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
https://standard.nbtc.go.th/getattachment/Standards/%E0%B8%A1%E0%B8%B2%E0%B8%95%E0%B8%A3%E0%B8%90%E0%B8%B2%E0%B8%99%E0%B8%97%E0%B8%B2%E0%B8%87%E0%B9%80%E0%B8%97%E0%B8%84%E0%B8%99%E0%B8%B4%E0%B8%84%E0%B8%82%E0%B8%AD%E0%B8%87%E0%B9%80%E0%B8%84%E0%B8%A3%E0%B8%B7%E0%B9%88%E0%B8%AD%E0%B8%87%E0%B9%82%E0%B8%97%E0%B8%A3%E0%B8%84%E0%B8%A1%E0%B8%99%E0%B8%B2%E0%B8%84%E0%B8%A1/1033-2565.pdf.aspx?lang=th-TH
|
||||
Thailand 920–925 MHz set max TX power to 27 dBm and enforce 10% duty cycle, aligned with NBTC regulations.
|
||||
*/
|
||||
RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD),
|
||||
RDEF(TH, 920.0f, 925.0f, 10, 0, 27, true, false, false),
|
||||
|
||||
/*
|
||||
433,05-434,7 Mhz 10 mW
|
||||
https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf
|
||||
*/
|
||||
RDEF(UA_433, 433.0f, 434.7f, 10, 0, 10, true, false, false),
|
||||
|
||||
/*
|
||||
868,0-868,6 Mhz 25 mW
|
||||
https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf
|
||||
*/
|
||||
RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD),
|
||||
RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD),
|
||||
RDEF(UA_868, 868.0f, 868.6f, 1, 0, 14, true, false, false),
|
||||
|
||||
/*
|
||||
Malaysia
|
||||
433 - 435 MHz at 100mW, no restrictions.
|
||||
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
|
||||
*/
|
||||
RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD),
|
||||
RDEF(MY_433, 433.0f, 435.0f, 100, 0, 20, true, false, false),
|
||||
|
||||
/*
|
||||
Malaysia
|
||||
@@ -179,14 +161,14 @@ const RegionInfo regions[] = {
|
||||
Frequency hopping is used for 919 - 923 MHz.
|
||||
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
|
||||
*/
|
||||
RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD),
|
||||
RDEF(MY_919, 919.0f, 924.0f, 100, 0, 27, true, true, false),
|
||||
|
||||
/*
|
||||
Singapore
|
||||
SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions.
|
||||
https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf
|
||||
*/
|
||||
RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD),
|
||||
RDEF(SG_923, 917.0f, 925.0f, 100, 0, 20, true, false, false),
|
||||
|
||||
/*
|
||||
Philippines
|
||||
@@ -196,9 +178,8 @@ const RegionInfo regions[] = {
|
||||
https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135
|
||||
*/
|
||||
|
||||
RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD),
|
||||
RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD),
|
||||
RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD),
|
||||
RDEF(PH_433, 433.0f, 434.7f, 100, 0, 10, true, false, false), RDEF(PH_868, 868.0f, 869.4f, 100, 0, 14, true, false, false),
|
||||
RDEF(PH_915, 915.0f, 918.0f, 100, 0, 24, true, false, false),
|
||||
|
||||
/*
|
||||
Kazakhstan
|
||||
@@ -206,38 +187,37 @@ const RegionInfo regions[] = {
|
||||
863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields
|
||||
https://github.com/meshtastic/firmware/issues/7204
|
||||
*/
|
||||
RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD),
|
||||
RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD),
|
||||
RDEF(KZ_433, 433.075f, 434.775f, 100, 0, 10, true, false, false),
|
||||
RDEF(KZ_863, 863.0f, 868.0f, 100, 0, 30, true, false, false),
|
||||
|
||||
/*
|
||||
Nepal
|
||||
865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use,
|
||||
specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf
|
||||
*/
|
||||
RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD),
|
||||
RDEF(NP_865, 865.0f, 868.0f, 100, 0, 30, true, false, false),
|
||||
|
||||
/*
|
||||
Brazil
|
||||
902 - 907.5 MHz , 1W power limit, no duty cycle restrictions
|
||||
https://github.com/meshtastic/firmware/issues/3741
|
||||
*/
|
||||
RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD),
|
||||
RDEF(BR_902, 902.0f, 907.5f, 100, 0, 30, true, false, false),
|
||||
|
||||
/*
|
||||
2.4 GHZ WLAN Band equivalent. Only for SX128x chips.
|
||||
*/
|
||||
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD),
|
||||
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 0, 10, true, false, true),
|
||||
|
||||
/*
|
||||
This needs to be last. Same as US.
|
||||
*/
|
||||
RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF)
|
||||
RDEF(UNSET, 902.0f, 928.0f, 100, 0, 30, true, false, false)
|
||||
|
||||
};
|
||||
|
||||
const RegionInfo *myRegion;
|
||||
bool RadioInterface::uses_default_frequency_slot = true;
|
||||
bool RadioInterface::uses_custom_channel_name = false;
|
||||
|
||||
static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1];
|
||||
|
||||
@@ -523,14 +503,45 @@ void initRegion()
|
||||
myRegion = r;
|
||||
}
|
||||
|
||||
const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
|
||||
void RadioInterface::bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig)
|
||||
{
|
||||
if (!loraConfig.use_preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find region info to determine whether "wide" LoRa is permitted (2.4 GHz uses wider bandwidth codes).
|
||||
const RegionInfo *r = regions;
|
||||
for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != code; r++)
|
||||
for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != loraConfig.region; r++)
|
||||
;
|
||||
return r;
|
||||
|
||||
const bool regionWideLora = r->wideLora;
|
||||
|
||||
float bwKHz = 0;
|
||||
uint8_t sf = 0;
|
||||
uint8_t cr = 0;
|
||||
modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr);
|
||||
|
||||
// If selected preset requests a bandwidth larger than the region span, fall back to LONG_FAST.
|
||||
if (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && (r->freqEnd - r->freqStart) < (bwKHz / 1000.0f)) {
|
||||
loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||||
modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr);
|
||||
}
|
||||
|
||||
loraConfig.bandwidth = bwKHzToCode(bwKHz);
|
||||
loraConfig.spread_factor = sf;
|
||||
}
|
||||
|
||||
/**
|
||||
* ## LoRaWAN for North America
|
||||
|
||||
LoRaWAN defines 64, 125 kHz channels from 902.3 to 914.9 MHz increments.
|
||||
|
||||
The maximum output power for North America is +30 dBM.
|
||||
|
||||
The band is from 902 to 928 MHz. It mentions channel number and its respective channel frequency. All the 13 channels are
|
||||
separated by 2.16 MHz with respect to the adjacent channels. Channel zero starts at 903.08 MHz center frequency.
|
||||
*/
|
||||
|
||||
uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received)
|
||||
{
|
||||
uint32_t pl = 0;
|
||||
@@ -740,7 +751,7 @@ void RadioInterface::saveFreq(float freq)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save our frequency slot (aka channel) for later reuse.
|
||||
* Save our channel for later reuse.
|
||||
*/
|
||||
void RadioInterface::saveChannelNum(uint32_t channel_num)
|
||||
{
|
||||
@@ -763,304 +774,113 @@ uint32_t RadioInterface::getChannelNum()
|
||||
return savedChannelNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error-level client notification. Safe to call when service is null (e.g. in tests).
|
||||
*/
|
||||
static void sendErrorNotification(const char *msg)
|
||||
{
|
||||
if (!service)
|
||||
return;
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
if (!cn)
|
||||
return;
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
snprintf(cn->message, sizeof(cn->message), "%s", msg);
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a region is valid for the current settings.
|
||||
* Returns false if not compatible.
|
||||
*/
|
||||
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
|
||||
{
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
|
||||
// If you are not licensed, you can't use ham regions.
|
||||
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
|
||||
char err_string[160];
|
||||
snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: validate or clamp a LoRa config against its region.
|
||||
* When clamp==false, returns false on first error (pure validation).
|
||||
* When clamp==true, fixes invalid settings in-place and returns true.
|
||||
*/
|
||||
bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp)
|
||||
{
|
||||
char err_string[160];
|
||||
float check_bw;
|
||||
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
|
||||
const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
|
||||
|
||||
// Check preset validity (only when use_preset is true)
|
||||
if (loraConfig.use_preset) {
|
||||
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
|
||||
|
||||
bool preset_valid = false;
|
||||
for (size_t i = 0; i < newRegion->getNumPresets(); i++) {
|
||||
if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) {
|
||||
preset_valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!preset_valid) {
|
||||
const char *defaultName = DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true);
|
||||
if (clamp) {
|
||||
snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s, using %s", presetName, newRegion->name,
|
||||
defaultName);
|
||||
} else {
|
||||
snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s", presetName, newRegion->name);
|
||||
}
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
|
||||
if (clamp) {
|
||||
loraConfig.modem_preset = newRegion->getDefaultPreset();
|
||||
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
check_bw = bwCodeToKHz(loraConfig.bandwidth);
|
||||
}
|
||||
|
||||
// Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region:
|
||||
// spacing = gap between slots (0 for continuous spectrum) and at the beginning of the band
|
||||
// padding = gap at the beginning and end of the slots (0 for no padding)
|
||||
float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz
|
||||
uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
|
||||
|
||||
// Check if the region supports the requested bandwidth
|
||||
if ((newRegion->freqEnd - newRegion->freqStart) < freqSlotWidth) {
|
||||
const float regionSpanKHz = (newRegion->freqEnd - newRegion->freqStart) * 1000.0f;
|
||||
snprintf(err_string, sizeof(err_string), "%s span %.0fkHz < requested %.0fkHz", newRegion->name, regionSpanKHz, check_bw);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
|
||||
if (clamp) {
|
||||
loraConfig.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora));
|
||||
check_bw = bwCodeToKHz(loraConfig.bandwidth);
|
||||
|
||||
// Recompute slot width and number of slots based on the new bandwidth
|
||||
freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz
|
||||
numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const char *channelName = channels.getName(channels.getPrimaryIndex());
|
||||
const char *presetNameDisplay =
|
||||
DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
|
||||
uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots;
|
||||
uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots;
|
||||
|
||||
if (loraConfig.override_frequency == 0) {
|
||||
|
||||
// Check if we use the default frequency slot
|
||||
uses_default_frequency_slot =
|
||||
(loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default
|
||||
(newRegion->profile->overrideSlot != 0 &&
|
||||
loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override
|
||||
((newRegion->profile->overrideSlot == 0) &&
|
||||
((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override
|
||||
|
||||
// check if user setting different to preset name
|
||||
uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0);
|
||||
|
||||
if (loraConfig.channel_num > numFreqSlots) {
|
||||
snprintf(err_string, sizeof(err_string), "Channel number %u invalid for %s, max is %u", loraConfig.channel_num,
|
||||
newRegion->name, numFreqSlots);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
|
||||
if (clamp) {
|
||||
if (uses_custom_channel_name) { // clamp to channel name hash
|
||||
loraConfig.channel_num =
|
||||
channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
|
||||
} else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot
|
||||
loraConfig.channel_num =
|
||||
newRegion->profile->overrideSlot; // use the override slot specified by the region profile
|
||||
uses_default_frequency_slot = true;
|
||||
} else if (loraConfig.use_preset) { // clamp to preset slot
|
||||
loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
|
||||
uses_default_frequency_slot = true;
|
||||
} else { // if not using preset, and no custom channel name, just clamp to default anyway
|
||||
uses_default_frequency_slot = true;
|
||||
};
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} // end of channel number check
|
||||
} else {
|
||||
// if we have a frequency override, we ignore the channel number and just use the override frequency
|
||||
snprintf(err_string, sizeof(err_string), "Frequency override in place, using %.3f", loraConfig.override_frequency);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig)
|
||||
{
|
||||
auto copy = loraConfig;
|
||||
return checkOrClampConfigLora(copy, false);
|
||||
}
|
||||
|
||||
void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig)
|
||||
{
|
||||
checkOrClampConfigLora(loraConfig, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull our channel settings etc... from protobufs to the dumb interface settings
|
||||
* Note: this must be given only settings which have been validated or clamped!
|
||||
*/
|
||||
void RadioInterface::applyModemConfig()
|
||||
{
|
||||
// Set up default configuration
|
||||
// No Sync Words in LORA mode
|
||||
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
myRegion = newRegion;
|
||||
|
||||
if (loraConfig.use_preset) {
|
||||
if (!validateConfigLora(loraConfig)) {
|
||||
loraConfig.modem_preset = newRegion->getDefaultPreset();
|
||||
}
|
||||
uint8_t newcr;
|
||||
modemPresetToParams(loraConfig.modem_preset, newRegion->wideLora, bw, sf, newcr);
|
||||
// If custom CR is being used already, check if the new preset is higher
|
||||
if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate < newcr) {
|
||||
cr = newcr;
|
||||
LOG_INFO("Default Coding Rate is higher than custom setting, using %u", cr);
|
||||
}
|
||||
// If the custom CR is higher than the preset, use it
|
||||
else if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate > newcr) {
|
||||
bool validConfig = false; // We need to check for a valid configuration
|
||||
while (!validConfig) {
|
||||
if (loraConfig.use_preset) {
|
||||
modemPresetToParams(loraConfig.modem_preset, myRegion->wideLora, bw, sf, cr);
|
||||
if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate != cr) {
|
||||
cr = loraConfig.coding_rate;
|
||||
LOG_INFO("Using custom Coding Rate %u", cr);
|
||||
}
|
||||
} else {
|
||||
sf = loraConfig.spread_factor;
|
||||
cr = loraConfig.coding_rate;
|
||||
LOG_INFO("Using custom Coding Rate %u", cr);
|
||||
} else {
|
||||
cr = newcr;
|
||||
bw = bwCodeToKHz(loraConfig.bandwidth);
|
||||
}
|
||||
|
||||
} else { // if not using preset, then just use the custom settings
|
||||
if (validateConfigLora(loraConfig)) {
|
||||
if ((myRegion->freqEnd - myRegion->freqStart) < bw / 1000) {
|
||||
const float regionSpanKHz = (myRegion->freqEnd - myRegion->freqStart) * 1000.0f;
|
||||
const float requestedBwKHz = bw;
|
||||
const bool isWideRequest = requestedBwKHz >= 499.5f; // treat as 500 kHz preset
|
||||
const char *presetName =
|
||||
DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
|
||||
|
||||
char err_string[160];
|
||||
if (isWideRequest) {
|
||||
snprintf(err_string, sizeof(err_string), "%s region too narrow for 500kHz preset (%s). Falling back to LongFast.",
|
||||
myRegion->name, presetName);
|
||||
} else {
|
||||
snprintf(err_string, sizeof(err_string), "%s region span %.0fkHz < requested %.0fkHz. Falling back to LongFast.",
|
||||
myRegion->name, regionSpanKHz, requestedBwKHz);
|
||||
}
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
snprintf(cn->message, sizeof(cn->message), "%s", err_string);
|
||||
service->sendClientNotification(cn);
|
||||
|
||||
// Set to default modem preset
|
||||
loraConfig.use_preset = true;
|
||||
loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||||
} else {
|
||||
LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults",
|
||||
newRegion->name);
|
||||
clampConfigLora(loraConfig);
|
||||
validConfig = true;
|
||||
}
|
||||
bw = bwCodeToKHz(loraConfig.bandwidth);
|
||||
sf = loraConfig.spread_factor;
|
||||
cr = loraConfig.coding_rate;
|
||||
}
|
||||
|
||||
power = loraConfig.tx_power;
|
||||
|
||||
if ((power == 0) || ((power > newRegion->powerLimit) && !devicestate.owner.is_licensed))
|
||||
power = newRegion->powerLimit;
|
||||
if ((power == 0) || ((power > myRegion->powerLimit) && !devicestate.owner.is_licensed))
|
||||
power = myRegion->powerLimit;
|
||||
|
||||
if (power == 0)
|
||||
power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of newRegion defaults
|
||||
power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of myRegion defaults
|
||||
// to 0, currently no region has an actual power limit of 0 [dBm] so we can assume regions which have this
|
||||
// variable set to 0 don't have a valid power limit)
|
||||
|
||||
// Set final tx_power back onto config
|
||||
loraConfig.tx_power = (int8_t)power; // cppcheck-suppress assignmentAddressToInteger
|
||||
|
||||
uint32_t channel_num;
|
||||
float freq;
|
||||
// Calculate the number of channels
|
||||
uint32_t numChannels = floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000)));
|
||||
|
||||
// Calculate number of frequency slots (aka Channels):
|
||||
// spacing = gap between channels (0 for continuous spectrum) and at the beginning of the band
|
||||
// padding = gap at the beginning and end of the channel (0 for no padding)
|
||||
float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (bw / 1000); // in MHz
|
||||
uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
|
||||
// If user has manually specified a channel num, then use that, otherwise generate one by hashing the name
|
||||
const char *channelName = channels.getName(channels.getPrimaryIndex());
|
||||
// channel_num is actually (channel_num - 1), since modulus (%) returns values from 0 to (numChannels - 1)
|
||||
uint32_t channel_num = (loraConfig.channel_num ? loraConfig.channel_num - 1 : hash(channelName)) % numChannels;
|
||||
|
||||
// Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one.
|
||||
// Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to
|
||||
// (numFreqSlots - 1).
|
||||
uint32_t presetNameHashSlot =
|
||||
hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots;
|
||||
// Check if we use the default frequency slot
|
||||
RadioInterface::uses_default_frequency_slot =
|
||||
channel_num ==
|
||||
hash(DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset)) % numChannels;
|
||||
|
||||
// Old frequency selection formula
|
||||
// float freq = myRegion->freqStart + ((((myRegion->freqEnd - myRegion->freqStart) / numChannels) / 2) * channel_num);
|
||||
|
||||
// New frequency selection formula
|
||||
float freq = myRegion->freqStart + (bw / 2000) + (channel_num * (bw / 1000));
|
||||
|
||||
// override if we have a verbatim frequency
|
||||
if (loraConfig.override_frequency) {
|
||||
freq = loraConfig.override_frequency;
|
||||
channel_num = -1;
|
||||
uses_default_frequency_slot = false;
|
||||
} else {
|
||||
|
||||
// If user has not manually specified a frequency slot, or has not specified one that is different than the default or the
|
||||
// override for the new region, then use the default or override. If the user has not specified one, but has specified a
|
||||
// custom channel name, then use the hash of that channel name to pick a frequency slot. Note that channel_num is actually
|
||||
// (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1).
|
||||
// NB: channel_num is also know as frequency slot but it's too late to fix now.
|
||||
if (uses_default_frequency_slot) {
|
||||
// if there's an override slot, use that
|
||||
if (newRegion->profile->overrideSlot != 0) {
|
||||
channel_num = newRegion->profile->overrideSlot - 1;
|
||||
} else {
|
||||
channel_num = presetNameHashSlot;
|
||||
}
|
||||
} else { // use the manually defined one
|
||||
channel_num = loraConfig.channel_num - 1;
|
||||
}
|
||||
|
||||
// Calculate frequency: freqStart is band edge, add half bandwidth (plus optional padding) to get middle of first channel
|
||||
// subsequent channels are spaced by freqSlotWidth
|
||||
freq = newRegion->freqStart + (bw / 2000) + newRegion->profile->padding + (channel_num * freqSlotWidth); // in MHz
|
||||
}
|
||||
|
||||
saveChannelNum(channel_num);
|
||||
saveFreq(freq + loraConfig.frequency_offset);
|
||||
const char *channelName = channels.getName(channels.getPrimaryIndex());
|
||||
|
||||
if (newRegion->wideLora) { // clamp if wide freq range
|
||||
preambleLength = wideLoraPreambleLengthDefault; // 12 is the default for operation above 2GHz
|
||||
} else {
|
||||
preambleLength =
|
||||
preambleLengthDefault; // 8 is default, but we use longer to increase the amount of sleep time when receiving
|
||||
}
|
||||
|
||||
slotTimeMsec = computeSlotTimeMsec();
|
||||
preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw);
|
||||
|
||||
LOG_INFO("Radio freq=%.3f, config.lora.frequency_offset=%.3f", freq, loraConfig.frequency_offset);
|
||||
LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", newRegion->name, channelName, loraConfig.modem_preset,
|
||||
LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", myRegion->name, channelName, loraConfig.modem_preset,
|
||||
channel_num, power);
|
||||
LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd,
|
||||
newRegion->freqEnd - newRegion->freqStart);
|
||||
LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw);
|
||||
if (newRegion->profile->overrideSlot != 0) {
|
||||
LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot);
|
||||
}
|
||||
LOG_INFO("myRegion->freqStart -> myRegion->freqEnd: %f -> %f (%f MHz)", myRegion->freqStart, myRegion->freqEnd,
|
||||
myRegion->freqEnd - myRegion->freqStart);
|
||||
LOG_INFO("numChannels: %d x %.3fkHz", numChannels, bw);
|
||||
LOG_INFO("channel_num: %d", channel_num + 1);
|
||||
LOG_INFO("frequency: %f", getFreq());
|
||||
LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec);
|
||||
} // end of applyModemConfig
|
||||
}
|
||||
|
||||
/** Slottime is the time to detect a transmission has started, consisting of:
|
||||
- CAD duration;
|
||||
@@ -1174,4 +994,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p)
|
||||
|
||||
sendingPacket = p;
|
||||
return p->encrypted.size + sizeof(PacketHeader);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,20 +92,15 @@ class RadioInterface
|
||||
uint8_t sf = 9;
|
||||
uint8_t cr = 5;
|
||||
|
||||
static constexpr uint8_t NUM_SYM_CAD =
|
||||
2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48
|
||||
static constexpr uint8_t NUM_SYM_CAD_24GHZ =
|
||||
4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280
|
||||
const uint8_t NUM_SYM_CAD = 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48
|
||||
const uint8_t NUM_SYM_CAD_24GHZ = 4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280
|
||||
uint32_t slotTimeMsec = computeSlotTimeMsec();
|
||||
uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving
|
||||
static constexpr uint16_t preambleLengthDefault =
|
||||
16; // 8 is default, but we use longer to increase the amount of sleep time when receiving
|
||||
static constexpr uint16_t wideLoraPreambleLengthDefault = 12; // 12 is default for wide Lora
|
||||
uint32_t preambleTimeMsec = 165; // calculated on startup, this is the default for LongFast
|
||||
static constexpr uint32_t PROCESSING_TIME_MSEC =
|
||||
4500; // time to construct, process and construct a packet again (empirically determined)
|
||||
static constexpr uint8_t CWmin = 3; // minimum CWsize
|
||||
static constexpr uint8_t CWmax = 8; // maximum CWsize
|
||||
uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving
|
||||
uint32_t preambleTimeMsec = 165; // calculated on startup, this is the default for LongFast
|
||||
const uint32_t PROCESSING_TIME_MSEC =
|
||||
4500; // time to construct, process and construct a packet again (empirically determined)
|
||||
const uint8_t CWmin = 3; // minimum CWsize
|
||||
const uint8_t CWmax = 8; // maximum CWsize
|
||||
|
||||
meshtastic_MeshPacket *sendingPacket = NULL; // The packet we are currently sending
|
||||
uint32_t lastTxStart = 0L;
|
||||
@@ -132,7 +127,7 @@ class RadioInterface
|
||||
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
|
||||
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
|
||||
*/
|
||||
// static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); // maybe superseded?
|
||||
static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
/**
|
||||
* Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving)
|
||||
@@ -239,20 +234,6 @@ class RadioInterface
|
||||
// Whether we use the default frequency slot given our LoRa config (region and modem preset)
|
||||
static bool uses_default_frequency_slot;
|
||||
|
||||
// Whether we have a custom channel name
|
||||
static bool uses_custom_channel_name;
|
||||
|
||||
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
|
||||
|
||||
// Check if a candidate region is compatible and valid.
|
||||
static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
// Check if a candidate radio configuration is valid.
|
||||
static bool validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
// Make a candidate radio configuration valid, even if it isn't.
|
||||
static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
protected:
|
||||
int8_t power = 17; // Set by applyModemConfig()
|
||||
|
||||
|
||||
@@ -246,24 +246,6 @@ bool RadioLibInterface::findInTxQueue(NodeNum from, PacketId id)
|
||||
return txQueue.find(from, id);
|
||||
}
|
||||
|
||||
bool RadioLibInterface::randomBytes(uint8_t *buffer, size_t length)
|
||||
{
|
||||
if (!buffer || length == 0 || !iface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Older RadioLib versions only expose random(min, max), so fill the buffer byte-by-byte.
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
int32_t value = iface->random(0, 255);
|
||||
if (value < 0) {
|
||||
return false;
|
||||
}
|
||||
buffer[i] = static_cast<uint8_t>(value & 0xFF);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** radio helper thread callback.
|
||||
We never immediately transmit after any operation (either Rx or Tx). Instead we should wait a random multiple of
|
||||
'slotTimes' (see definition in RadioInterface.h) taken from a contention window (CW) to lower the chance of collision.
|
||||
@@ -605,4 +587,4 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp)
|
||||
|
||||
return res == RADIOLIB_ERR_NONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,12 +172,6 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified
|
||||
/** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */
|
||||
virtual bool findInTxQueue(NodeNum from, PacketId id) override;
|
||||
|
||||
/**
|
||||
* Request randomness sourced from the LoRa modem, if supported by the active RadioLib interface.
|
||||
* @return true if len bytes were produced, false otherwise.
|
||||
*/
|
||||
bool randomBytes(uint8_t *buffer, size_t length);
|
||||
|
||||
private:
|
||||
/** if we have something waiting to send, start a short (random) timer so we can come check for collision before actually
|
||||
* doing the transmit */
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "meshUtils.h"
|
||||
#include "modules/RoutingModule.h"
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
#endif
|
||||
@@ -98,20 +95,6 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
|
||||
return true;
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// When router_preserve_hops is enabled, preserve hops for decoded packets that are not
|
||||
// position or telemetry (those have their own exhaust_hop controls).
|
||||
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled &&
|
||||
moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) {
|
||||
LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p));
|
||||
if (trafficManagementModule) {
|
||||
trafficManagementModule->recordRouterHopPreserved();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
// For subsequent hops, check if previous relay is a favorite router
|
||||
// Optimized search for favorite routers with matching last byte
|
||||
// Check ordering optimized for IoT devices (cheapest checks first)
|
||||
@@ -875,12 +858,6 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDropPacketForPreHop(*p)) {
|
||||
logHopStartDrop(*p, "pre-hop drop");
|
||||
packetPool.release(p);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldFilterReceived(p)) {
|
||||
LOG_DEBUG("Incoming msg was filtered from 0x%x", p->from);
|
||||
packetPool.release(p);
|
||||
|
||||
@@ -245,21 +245,14 @@ template <typename T> bool SX126xInterface<T>::reconfigure()
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
|
||||
limitPower(SX126X_MAX_POWER);
|
||||
// Make sure we reach the minimum power supported to turn the chip on (-9dBm)
|
||||
if (power < -9)
|
||||
power = -9;
|
||||
if (power > SX126X_MAX_POWER) // This chip has lower power limits than some
|
||||
power = SX126X_MAX_POWER;
|
||||
|
||||
err = lora.setOutputPower(power);
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err);
|
||||
assert(err == RADIOLIB_ERR_NONE);
|
||||
|
||||
// Apply RX gain mode — valid in STDBY (datasheet §9.6), matches resetAGC() pattern
|
||||
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err);
|
||||
|
||||
startReceive(); // restart receiving
|
||||
|
||||
return RADIOLIB_ERR_NONE;
|
||||
|
||||
@@ -144,7 +144,8 @@ template <typename T> bool SX128xInterface<T>::reconfigure()
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
|
||||
limitPower(SX128X_MAX_POWER);
|
||||
if (power > SX128X_MAX_POWER) // This chip has lower power limits than some
|
||||
power = SX128X_MAX_POWER;
|
||||
|
||||
err = lora.setOutputPower(power);
|
||||
if (err != RADIOLIB_ERR_NONE)
|
||||
|
||||
@@ -69,22 +69,6 @@ static inline int get_max_num_nodes()
|
||||
/// Max number of channels allowed
|
||||
#define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0]))
|
||||
|
||||
// Traffic Management module configuration
|
||||
// Enable per-variant by defining HAS_TRAFFIC_MANAGEMENT=1 in variant.h
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
#define HAS_TRAFFIC_MANAGEMENT 0
|
||||
#endif
|
||||
|
||||
// Cache size for traffic management (number of nodes to track)
|
||||
// Can be overridden per-variant based on available memory
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000
|
||||
#else
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic
|
||||
/// returns the encoded packet size
|
||||
size_t pb_encode_to_bytes(uint8_t *destbuf, size_t destbufsize, const pb_msgdesc_t *fields, const void *src_struct);
|
||||
@@ -106,4 +90,4 @@ bool writecb(pb_ostream_t *stream, const uint8_t *buf, size_t count);
|
||||
*/
|
||||
bool is_in_helper(uint32_t n, const uint32_t *array, pb_size_t count);
|
||||
|
||||
#define is_in_repeated(name, n) is_in_helper(n, name, name##_count)
|
||||
#define is_in_repeated(name, n) is_in_helper(n, name, name##_count)
|
||||
+103
-126
@@ -24,9 +24,7 @@
|
||||
|
||||
#include "Default.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "RadioInterface.h"
|
||||
#include "TypeConversions.h"
|
||||
#include "mesh/RadioLibInterface.h"
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
@@ -199,35 +197,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
handleSetOwner(r->set_owner);
|
||||
break;
|
||||
|
||||
case meshtastic_AdminMessage_set_config_tag: {
|
||||
case meshtastic_AdminMessage_set_config_tag:
|
||||
LOG_DEBUG("Client set config");
|
||||
|
||||
// Non-LoRa configs need no further validation.
|
||||
if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) {
|
||||
LOG_DEBUG("Non-LoRa config, applying directly");
|
||||
handleSetConfig(r->set_config, fromOthers);
|
||||
break;
|
||||
}
|
||||
|
||||
// Only LORA_24 requires hardware capability validation.
|
||||
if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
|
||||
LOG_DEBUG("LoRa config, region is not LORA_24, applying directly");
|
||||
handleSetConfig(r->set_config, fromOthers);
|
||||
break;
|
||||
}
|
||||
|
||||
// Hardware supports 2.4 GHz — apply the config.
|
||||
// Fail closed: null instance is treated as incapable.
|
||||
if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) {
|
||||
LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying");
|
||||
handleSetConfig(r->set_config, fromOthers);
|
||||
break;
|
||||
}
|
||||
|
||||
LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region");
|
||||
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
||||
handleSetConfig(r->set_config);
|
||||
break;
|
||||
}
|
||||
|
||||
case meshtastic_AdminMessage_set_module_config_tag:
|
||||
LOG_DEBUG("Client set module config");
|
||||
@@ -480,7 +453,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
#if HAS_SCREEN
|
||||
IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0));
|
||||
#endif
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_RP2040)
|
||||
enterDfuMode();
|
||||
#endif
|
||||
break;
|
||||
@@ -653,7 +626,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
|
||||
}
|
||||
}
|
||||
|
||||
void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
void AdminModule::handleSetConfig(const meshtastic_Config &c)
|
||||
{
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
auto existingRole = config.device.role;
|
||||
@@ -797,57 +770,18 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
validatedLora.spread_factor = LORA_SF_DEFAULT;
|
||||
}
|
||||
|
||||
// If we're setting a new region, check the region is valid and then init the region or discard the change
|
||||
if (validatedLora.region != myRegion->code) {
|
||||
// Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid
|
||||
if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) {
|
||||
// If we're setting region for the first time, init the region and regenerate the keys
|
||||
if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
}
|
||||
#endif
|
||||
// new region is valid and we're coming from an unset region, so enable tx
|
||||
validatedLora.tx_enabled = true;
|
||||
}
|
||||
// If we're unsetting the region for some reason, disable tx
|
||||
if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
validatedLora.tx_enabled = false;
|
||||
}
|
||||
// Ensure initRegion() uses the newly validated region
|
||||
config.lora.region = validatedLora.region;
|
||||
initRegion();
|
||||
if (myRegion->dutyCycle < 100) {
|
||||
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
||||
}
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
}
|
||||
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
|
||||
} else {
|
||||
// Region validation has failed, so just copy all of the old config over the new config
|
||||
validatedLora = oldLoraConfig;
|
||||
}
|
||||
} // end of new region handling
|
||||
|
||||
if (!RadioInterface::validateConfigLora(validatedLora)) {
|
||||
if (fromOthers) {
|
||||
LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
|
||||
// modem_preset set to use the old setting if the check fails
|
||||
validatedLora.modem_preset = oldLoraConfig.modem_preset;
|
||||
} else {
|
||||
LOG_WARN("Invalid LoRa config received from client, using corrected values");
|
||||
RadioInterface::clampConfigLora(validatedLora);
|
||||
}
|
||||
// use_preset and bandwidth are coerced into valid values by the check.
|
||||
// If no lora radio parameters change, don't need to reboot
|
||||
if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region &&
|
||||
oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth &&
|
||||
oldLoraConfig.spread_factor == validatedLora.spread_factor &&
|
||||
oldLoraConfig.coding_rate == validatedLora.coding_rate && oldLoraConfig.tx_power == validatedLora.tx_power &&
|
||||
oldLoraConfig.frequency_offset == validatedLora.frequency_offset &&
|
||||
oldLoraConfig.override_frequency == validatedLora.override_frequency &&
|
||||
oldLoraConfig.channel_num == validatedLora.channel_num &&
|
||||
oldLoraConfig.sx126x_rx_boosted_gain == validatedLora.sx126x_rx_boosted_gain) {
|
||||
requiresReboot = false;
|
||||
}
|
||||
|
||||
// All LoRa radio changes apply live via configChanged observer → reconfigure().
|
||||
// reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive.
|
||||
requiresReboot = false;
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
// If running on portduino and using SimRadio, do not require reboot
|
||||
if (SimRadio::instance) {
|
||||
@@ -863,21 +797,63 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
digitalWrite(RF95_FAN_EN, HIGH ^ 0);
|
||||
}
|
||||
#endif
|
||||
config.lora = validatedLora;
|
||||
|
||||
#if HAS_LORA_FEM
|
||||
// Apply FEM LNA mode from config (only meaningful on hardware that supports it)
|
||||
// Note that a rejected lora config will revert this as well.
|
||||
if (loraFEMInterface.isLnaCanControl()) {
|
||||
loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
|
||||
} else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
|
||||
loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
|
||||
} else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
|
||||
// Hardware FEM does not support LNA control; normalize stored config to match actual capability
|
||||
LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT");
|
||||
validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
|
||||
config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
|
||||
}
|
||||
#endif
|
||||
// If we're setting region for the first time, init the region and regenerate the keys
|
||||
if (isRegionUnset && config.lora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (!owner.is_licensed) {
|
||||
bool keygenSuccess = false;
|
||||
if (config.security.private_key.size == 32) {
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
} else {
|
||||
LOG_INFO("Generate new PKI keys");
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
config.lora.tx_enabled = true;
|
||||
initRegion();
|
||||
if (myRegion->dutyCycle < 100) {
|
||||
config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
||||
}
|
||||
// Compare the entire string, we are sure of the length as a topic has never been set
|
||||
if (strcmp(moduleConfig.mqtt.root, default_mqtt_root) == 0) {
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
|
||||
}
|
||||
}
|
||||
if (config.lora.region != myRegion->code) {
|
||||
// Region has changed so check whether there is a regulatory one we should be using instead.
|
||||
// Additionally as a side-effect, assume a new value under myRegion
|
||||
initRegion();
|
||||
|
||||
config.lora = validatedLora; // Finally, return the validated config back to the main config
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
}
|
||||
|
||||
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_Config_bluetooth_tag:
|
||||
@@ -925,10 +901,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
}
|
||||
if (requiresReboot && !hasOpenEditTransaction) {
|
||||
disableBluetooth();
|
||||
} // end of switch case which_payload_variant
|
||||
}
|
||||
|
||||
saveChanges(changes, requiresReboot);
|
||||
} // end of handleSetConfig
|
||||
}
|
||||
|
||||
bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
|
||||
{
|
||||
@@ -1034,11 +1010,6 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
|
||||
moduleConfig.statusmessage = c.payload_variant.statusmessage;
|
||||
shouldReboot = false;
|
||||
break;
|
||||
case meshtastic_ModuleConfig_traffic_management_tag:
|
||||
LOG_INFO("Set module config: Traffic Management");
|
||||
moduleConfig.has_traffic_management = true;
|
||||
moduleConfig.traffic_management = c.payload_variant.traffic_management;
|
||||
break;
|
||||
}
|
||||
saveChanges(SEGMENT_MODULECONFIG, shouldReboot);
|
||||
return true;
|
||||
@@ -1153,85 +1124,78 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
|
||||
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
|
||||
|
||||
if (req.decoded.want_response) {
|
||||
const char *configName = "?";
|
||||
switch (configType) {
|
||||
case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG:
|
||||
configName = "MQTT";
|
||||
LOG_INFO("Get module config: MQTT");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
|
||||
res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG:
|
||||
configName = "Serial";
|
||||
LOG_INFO("Get module config: Serial");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_serial_tag;
|
||||
res.get_module_config_response.payload_variant.serial = moduleConfig.serial;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG:
|
||||
configName = "External Notification";
|
||||
LOG_INFO("Get module config: External Notification");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag;
|
||||
res.get_module_config_response.payload_variant.external_notification = moduleConfig.external_notification;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG:
|
||||
configName = "Store & Forward";
|
||||
LOG_INFO("Get module config: Store & Forward");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag;
|
||||
res.get_module_config_response.payload_variant.store_forward = moduleConfig.store_forward;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG:
|
||||
configName = "Range Test";
|
||||
LOG_INFO("Get module config: Range Test");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_range_test_tag;
|
||||
res.get_module_config_response.payload_variant.range_test = moduleConfig.range_test;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG:
|
||||
configName = "Telemetry";
|
||||
LOG_INFO("Get module config: Telemetry");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag;
|
||||
res.get_module_config_response.payload_variant.telemetry = moduleConfig.telemetry;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG:
|
||||
configName = "Canned Message";
|
||||
LOG_INFO("Get module config: Canned Message");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag;
|
||||
res.get_module_config_response.payload_variant.canned_message = moduleConfig.canned_message;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG:
|
||||
configName = "Audio";
|
||||
LOG_INFO("Get module config: Audio");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_audio_tag;
|
||||
res.get_module_config_response.payload_variant.audio = moduleConfig.audio;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG:
|
||||
configName = "Remote Hardware";
|
||||
LOG_INFO("Get module config: Remote Hardware");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag;
|
||||
res.get_module_config_response.payload_variant.remote_hardware = moduleConfig.remote_hardware;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG:
|
||||
configName = "Neighbor Info";
|
||||
LOG_INFO("Get module config: Neighbor Info");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag;
|
||||
res.get_module_config_response.payload_variant.neighbor_info = moduleConfig.neighbor_info;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG:
|
||||
configName = "Detection Sensor";
|
||||
LOG_INFO("Get module config: Detection Sensor");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag;
|
||||
res.get_module_config_response.payload_variant.detection_sensor = moduleConfig.detection_sensor;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG:
|
||||
configName = "Ambient Lighting";
|
||||
LOG_INFO("Get module config: Ambient Lighting");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag;
|
||||
res.get_module_config_response.payload_variant.ambient_lighting = moduleConfig.ambient_lighting;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG:
|
||||
configName = "Paxcounter";
|
||||
LOG_INFO("Get module config: Paxcounter");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
|
||||
res.get_module_config_response.payload_variant.paxcounter = moduleConfig.paxcounter;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_STATUSMESSAGE_CONFIG:
|
||||
configName = "StatusMessage";
|
||||
LOG_INFO("Get module config: StatusMessage");
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag;
|
||||
res.get_module_config_response.payload_variant.statusmessage = moduleConfig.statusmessage;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_TRAFFICMANAGEMENT_CONFIG:
|
||||
configName = "Traffic Management";
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
|
||||
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
|
||||
break;
|
||||
}
|
||||
LOG_INFO("Get module config: %s", configName);
|
||||
|
||||
// NOTE: The phone app needs to know the ls_secsvalue so it can properly expect sleep behavior.
|
||||
// So even if we internally use 0 to represent 'use default' we still need to send the value we are
|
||||
@@ -1414,17 +1378,23 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic
|
||||
void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
|
||||
{
|
||||
// Validate ham parameters before setting since this would bypass validation in the owner struct
|
||||
const char *fieldsToCheck[] = {p.call_sign, p.short_name};
|
||||
const char *fieldNames[] = {"call_sign", "short_name"};
|
||||
for (int i = 0; i < 2; i++) {
|
||||
if (*fieldsToCheck[i]) {
|
||||
const char *start = fieldsToCheck[i];
|
||||
while (*start && isspace((unsigned char)*start))
|
||||
start++;
|
||||
if (*start == '\0') {
|
||||
LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]);
|
||||
return;
|
||||
}
|
||||
if (*p.call_sign) {
|
||||
const char *start = p.call_sign;
|
||||
// Skip all whitespace
|
||||
while (*start && isspace((unsigned char)*start))
|
||||
start++;
|
||||
if (*start == '\0') {
|
||||
LOG_WARN("Rejected ham call_sign: must contain at least 1 non-whitespace character");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (*p.short_name) {
|
||||
const char *start = p.short_name;
|
||||
while (*start && isspace((unsigned char)*start))
|
||||
start++;
|
||||
if (*start == '\0') {
|
||||
LOG_WARN("Rejected ham short_name: must contain at least 1 non-whitespace character");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1519,8 +1489,15 @@ void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent
|
||||
LOG_DEBUG("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code,
|
||||
inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y);
|
||||
|
||||
// Create InputEvent for injection
|
||||
InputEvent event = {.inputEvent = (input_broker_event)inputEvent.event_code,
|
||||
// Create InputEvent for injection.
|
||||
//
|
||||
// `.source` MUST be a non-null C string: the LOG_INFO below formats it
|
||||
// with %s, and passing NULL to the esp-log formatter crashes with
|
||||
// Guru Meditation LoadProhibited at strlen(NULL). Other InputBroker
|
||||
// sources (buttons, rotary) always set this; the admin path was the
|
||||
// only one leaving it default-null.
|
||||
InputEvent event = {.source = "admin",
|
||||
.inputEvent = (input_broker_event)inputEvent.event_code,
|
||||
.kbchar = (unsigned char)inputEvent.kb_char,
|
||||
.touchX = inputEvent.touch_x,
|
||||
.touchY = inputEvent.touch_y};
|
||||
|
||||
@@ -60,11 +60,7 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
||||
*/
|
||||
void handleSetOwner(const meshtastic_User &o);
|
||||
void handleSetChannel(const meshtastic_Channel &cc);
|
||||
|
||||
protected:
|
||||
void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
|
||||
|
||||
private:
|
||||
void handleSetConfig(const meshtastic_Config &c);
|
||||
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
|
||||
void handleSetChannel();
|
||||
void handleSetHamMode(const meshtastic_HamParameters &req);
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
#include "modules/PowerStressModule.h"
|
||||
#endif
|
||||
#include "modules/RoutingModule.h"
|
||||
#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#include "modules/TextMessageModule.h"
|
||||
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
|
||||
#include "modules/TraceRouteModule.h"
|
||||
@@ -123,14 +120,6 @@ void setupModules()
|
||||
#if !MESHTASTIC_EXCLUDE_REPLYBOT
|
||||
new ReplyBotModule();
|
||||
#endif
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT
|
||||
// Instantiate only when enabled to avoid extra memory use and background work.
|
||||
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled) {
|
||||
trafficManagementModule = new TrafficManagementModule();
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_ADMIN
|
||||
adminModule = new AdminModule();
|
||||
#endif
|
||||
|
||||
@@ -651,7 +651,7 @@ void SerialModule::processWXSerial()
|
||||
LOG_INFO("WS8X : %i %.1fg%.1f %.1fv %.1fv %.1fC rain: %.1f, %i sum", atoi(windDir), strtof(windVel, nullptr),
|
||||
strtof(windGust, nullptr), batVoltageF, capVoltageF, temperatureF, rain, rainSum);
|
||||
}
|
||||
if (gotwind && !Throttle::isWithinTimespanMs(lastAveraged, averageIntervalMillis) && velCount > 0 && dirCount > 0) {
|
||||
if (gotwind && !Throttle::isWithinTimespanMs(lastAveraged, averageIntervalMillis)) {
|
||||
// calculate averages and send to the mesh
|
||||
float velAvg = 1.0 * velSum / velCount;
|
||||
|
||||
|
||||
@@ -29,23 +29,10 @@ int32_t StatusMessageModule::runOnce()
|
||||
ProcessMessage StatusMessageModule::handleReceived(const meshtastic_MeshPacket &mp)
|
||||
{
|
||||
if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
|
||||
meshtastic_StatusMessage incomingMessage = meshtastic_StatusMessage_init_zero;
|
||||
|
||||
meshtastic_StatusMessage incomingMessage;
|
||||
if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_StatusMessage_fields,
|
||||
&incomingMessage)) {
|
||||
|
||||
LOG_INFO("Received a NodeStatus message %s", incomingMessage.status);
|
||||
|
||||
RecentStatus entry;
|
||||
entry.fromNodeId = mp.from;
|
||||
entry.statusText = incomingMessage.status;
|
||||
|
||||
recentReceived.push_back(std::move(entry));
|
||||
|
||||
// Keep only last MAX_RECENT_STATUSMESSAGES
|
||||
if (recentReceived.size() > MAX_RECENT_STATUSMESSAGES) {
|
||||
recentReceived.erase(recentReceived.begin()); // drop oldest
|
||||
}
|
||||
}
|
||||
}
|
||||
return ProcessMessage::CONTINUE;
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
#include "SinglePortModule.h"
|
||||
#include "configuration.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class StatusMessageModule : public SinglePortModule, private concurrency::OSThread
|
||||
{
|
||||
|
||||
public:
|
||||
/** Constructor
|
||||
* name is for debugging output
|
||||
@@ -20,28 +19,16 @@ class StatusMessageModule : public SinglePortModule, private concurrency::OSThre
|
||||
this->setInterval(1000 * 12 * 60 * 60);
|
||||
}
|
||||
// TODO: If we have a string, set the initial delay (15 minutes maybe)
|
||||
|
||||
// Keep vector from reallocating as we fill up to MAX_RECENT_STATUSMESSAGES
|
||||
recentReceived.reserve(MAX_RECENT_STATUSMESSAGES);
|
||||
}
|
||||
|
||||
virtual int32_t runOnce() override;
|
||||
|
||||
struct RecentStatus {
|
||||
uint32_t fromNodeId; // mp.from
|
||||
std::string statusText; // incomingMessage.status
|
||||
};
|
||||
|
||||
const std::vector<RecentStatus> &getRecentReceived() const { return recentReceived; }
|
||||
|
||||
protected:
|
||||
/** Called to handle a particular incoming message
|
||||
*/
|
||||
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
|
||||
private:
|
||||
static constexpr size_t MAX_RECENT_STATUSMESSAGES = 5;
|
||||
std::vector<RecentStatus> recentReceived;
|
||||
};
|
||||
|
||||
extern StatusMessageModule *statusMessageModule;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user