mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-06 12:10:59 +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
|
||||
|
||||
|
||||
@@ -216,14 +216,14 @@ Multiple display driver families in `src/graphics/`:
|
||||
|
||||
- **OLED**: SSD1306, SH1106, ST7567
|
||||
- **TFT**: TFTDisplay (LovyanGFX-based)
|
||||
- **E-Ink**: `src/graphics/BaseUIEInkDisplay.*` is the OLEDDisplay-compatible adapter (peer of `TFTDisplay`). The hardware layer it drives lives in `src/graphics/eink/` — chipset drivers in `Drivers/`, panel profiles in `Panels/`, optional `Backlight/`. Shared by both BaseUI-on-eink and InkHUD builds.
|
||||
- **E-Ink**: EInkDisplay2, EInkDynamicDisplay, EInkParallelDisplay
|
||||
|
||||
**InkHUD** (`src/graphics/niche/`) is an event-driven e-ink UI framework that sits on top of the `graphics/eink/` layer:
|
||||
**InkHUD** (`src/graphics/niche/InkHUD/`) is an event-driven e-ink UI framework:
|
||||
|
||||
- Applet-based architecture — modular display tiles
|
||||
- Read-only, static display optimized for minimal refreshes and low power
|
||||
- Configured per-variant via `nicheGraphics.h`
|
||||
- Build helpers in top-level `platformio.ini` — `[niche]` pulls `graphics/eink/` only (BaseUI path), `[inkhud]` extends it with `graphics/niche/`
|
||||
- Separate PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
|
||||
|
||||
### Input System
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ CallbackObserver<MyModule, const meshtastic::Status *> statusObserver =
|
||||
|
||||
Add test suite in `test/test_mymodule/`:
|
||||
|
||||
```text
|
||||
```
|
||||
test/
|
||||
└── test_mymodule/
|
||||
└── test_main.cpp
|
||||
|
||||
@@ -6,7 +6,7 @@ Guide for adding a new Meshtastic hardware variant to the firmware.
|
||||
|
||||
Create under `variants/<arch>/<name>/`:
|
||||
|
||||
```text
|
||||
```
|
||||
variants/
|
||||
├── esp32/ # ESP32
|
||||
├── esp32s3/ # ESP32-S3
|
||||
@@ -132,7 +132,7 @@ For e-ink display variants using the InkHUD framework, add `nicheGraphics.h`:
|
||||
// Configure display, applets, and refresh behavior per device
|
||||
```
|
||||
|
||||
InkHUD and the shared E-Ink layer are wired up via the top-level `platformio.ini` `[niche]` (BaseUI + driver/panel layer in `src/graphics/eink/`) and `[inkhud]` (adds the InkHUD UI in `src/graphics/niche/`). Variants opt in with `extends = ..., niche` or `extends = ..., inkhud`.
|
||||
InkHUD has its own PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
|
||||
|
||||
## I2C Device Detection
|
||||
|
||||
|
||||
@@ -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"
|
||||
+5
-25
@@ -8,30 +8,10 @@ extra_configs =
|
||||
variants/*/*.ini
|
||||
variants/*/*/platformio.ini
|
||||
variants/*/diy/*/platformio.ini
|
||||
src/graphics/niche/InkHUD/PlatformioConfig.ini
|
||||
|
||||
description = Meshtastic
|
||||
|
||||
; E-Ink / NicheGraphics build helpers.
|
||||
[niche]
|
||||
build_src_filter =
|
||||
+<graphics/eink/>
|
||||
build_flags =
|
||||
-D MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
[inkhud]
|
||||
build_src_filter =
|
||||
${niche.build_src_filter}
|
||||
+<graphics/niche/>
|
||||
build_flags =
|
||||
${niche.build_flags}
|
||||
-D MESHTASTIC_INCLUDE_INKHUD ; Use InkHUD as the UI
|
||||
-D MESHTASTIC_EXCLUDE_SCREEN ; Suppress default Screen class
|
||||
-D MESHTASTIC_EXCLUDE_INPUTBROKER ; Suppress default input handling
|
||||
-D HAS_BUTTON=0 ; Suppress default ButtonThread
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=GFX_Root packageName=ZinggJM/GFX_Root
|
||||
https://github.com/ZinggJM/GFX_Root/archive/3195764e352a0d2567c8d277ac408ca7293a99b0.zip ; Used by InkHUD as a "slimmer" version of AdafruitGFX
|
||||
|
||||
[env]
|
||||
test_build_src = true
|
||||
extra_scripts =
|
||||
@@ -76,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
|
||||
@@ -120,7 +102,7 @@ build_unflags =
|
||||
-std=gnu++11
|
||||
build_flags = ${env.build_flags} -Os
|
||||
-std=gnu++17
|
||||
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/> -<graphics/eink/>
|
||||
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/>
|
||||
|
||||
; Common libs for communicating over TCP/IP networks such as MQTT
|
||||
[networking_base]
|
||||
@@ -144,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]
|
||||
@@ -245,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)
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./BaseUIEInkDisplay.h"
|
||||
|
||||
#include "configuration.h"
|
||||
#include "main.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
BaseUIEInkDisplay::BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation) : driver(driver), rotation(rotation & 0x3)
|
||||
{
|
||||
this->geometry = GEOMETRY_RAWMODE;
|
||||
|
||||
// BaseUI draws in UI orientation. Physical panel dimensions are swapped for 90°/270°.
|
||||
const bool swap = (this->rotation == 1) || (this->rotation == 3);
|
||||
this->displayWidth = swap ? driver->height : driver->width;
|
||||
this->displayHeight = swap ? driver->width : driver->height;
|
||||
|
||||
uint16_t shortSide = min(displayWidth, displayHeight);
|
||||
uint16_t longSide = max(displayWidth, displayHeight);
|
||||
if (shortSide % 8 != 0)
|
||||
shortSide = (shortSide | 7) + 1;
|
||||
this->displayBufferSize = longSide * (shortSide / 8);
|
||||
|
||||
// Panel-native row-major buffer
|
||||
panelRowBytes = ((driver->width - 1) / 8) + 1;
|
||||
panelBufferSize = panelRowBytes * driver->height;
|
||||
panelBuffer = new uint8_t[panelBufferSize];
|
||||
memset(panelBuffer, 0xFF, panelBufferSize); // All white
|
||||
}
|
||||
|
||||
BaseUIEInkDisplay::~BaseUIEInkDisplay()
|
||||
{
|
||||
delete[] panelBuffer;
|
||||
}
|
||||
|
||||
bool BaseUIEInkDisplay::connect()
|
||||
{
|
||||
LOG_INFO("Init BaseUI E-Ink (%u x %u, rot %u)", driver->width, driver->height, rotation);
|
||||
return true;
|
||||
}
|
||||
|
||||
void BaseUIEInkDisplay::addFrameFlag(frameFlagTypes flag)
|
||||
{
|
||||
frameFlags = (frameFlagTypes)(frameFlags | flag);
|
||||
}
|
||||
|
||||
void BaseUIEInkDisplay::setDisplayResilience(uint8_t fastPerFull, float stressMultiplier)
|
||||
{
|
||||
this->fastPerFull = (fastPerFull == 0) ? 1 : fastPerFull;
|
||||
this->stressMultiplier = stressMultiplier;
|
||||
}
|
||||
|
||||
void BaseUIEInkDisplay::joinAsyncRefresh()
|
||||
{
|
||||
if (driver->busy())
|
||||
driver->await();
|
||||
}
|
||||
|
||||
// OLEDDisplayUi tick path. Honours rate-limit unless flags demand otherwise.
|
||||
void BaseUIEInkDisplay::display()
|
||||
{
|
||||
const bool demandFast = frameFlags & DEMAND_FAST;
|
||||
const bool cosmetic = frameFlags & COSMETIC;
|
||||
const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
|
||||
|
||||
if (!demandFast && !cosmetic && !unlimitedFast) {
|
||||
if (!forceDisplay(lastDrawMsec == 0 ? 0 : 1000))
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
forceDisplay(0);
|
||||
}
|
||||
|
||||
// Keyframe path. Returns true if a frame was pushed (sets lastDrawMsec).
|
||||
bool BaseUIEInkDisplay::forceDisplay(uint32_t msecLimit)
|
||||
{
|
||||
const uint32_t now = millis();
|
||||
if (lastDrawMsec != 0 && (now - lastDrawMsec) < msecLimit)
|
||||
return false;
|
||||
|
||||
const bool blocking = frameFlags & BLOCKING;
|
||||
Drivers::EInk::UpdateTypes type = decide();
|
||||
|
||||
// Don't pile frames on top of a running update - wait it out.
|
||||
if (driver->busy())
|
||||
driver->await();
|
||||
|
||||
const bool pushed = commit(type, blocking);
|
||||
if (pushed)
|
||||
lastDrawMsec = now;
|
||||
|
||||
// Reset flags for next frame
|
||||
frameFlags = BACKGROUND;
|
||||
return pushed;
|
||||
}
|
||||
|
||||
bool BaseUIEInkDisplay::commit(Drivers::EInk::UpdateTypes type, bool blocking)
|
||||
{
|
||||
uint32_t hash = repack();
|
||||
|
||||
// Skip if frame unchanged. Exception: caller explicitly wants a refresh (COSMETIC or FULL).
|
||||
if (hash == lastHash && type != Drivers::EInk::UpdateTypes::FULL && lastDrawMsec != 0)
|
||||
return false;
|
||||
lastHash = hash;
|
||||
|
||||
// Fall back to FULL on panels that don't advertise FAST support.
|
||||
if (type == Drivers::EInk::UpdateTypes::FAST && !driver->supports(Drivers::EInk::UpdateTypes::FAST))
|
||||
type = Drivers::EInk::UpdateTypes::FULL;
|
||||
|
||||
driver->update(panelBuffer, type);
|
||||
|
||||
if (blocking)
|
||||
driver->await();
|
||||
return true;
|
||||
}
|
||||
|
||||
Drivers::EInk::UpdateTypes BaseUIEInkDisplay::decide()
|
||||
{
|
||||
typedef Drivers::EInk::UpdateTypes UT;
|
||||
|
||||
const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
|
||||
|
||||
// Explicit flag wins outright
|
||||
if (frameFlags & COSMETIC) {
|
||||
fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
|
||||
return UT::FULL;
|
||||
}
|
||||
if (frameFlags & DEMAND_FAST) {
|
||||
if (!unlimitedFast) {
|
||||
fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
|
||||
}
|
||||
return UT::FAST;
|
||||
}
|
||||
|
||||
const bool explicitFast = frameFlags & RESPONSIVE;
|
||||
|
||||
if (explicitFast || unlimitedFast) {
|
||||
if (!unlimitedFast) {
|
||||
fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
|
||||
}
|
||||
return UT::FAST;
|
||||
}
|
||||
|
||||
// BACKGROUND / unspecified: let debt decide
|
||||
if (fullRefreshDebt >= 1.0f) {
|
||||
fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
|
||||
return UT::FULL;
|
||||
}
|
||||
fullRefreshDebt += 1.0f / fastPerFull;
|
||||
return UT::FAST;
|
||||
}
|
||||
|
||||
uint32_t BaseUIEInkDisplay::repack()
|
||||
{
|
||||
memset(panelBuffer, 0xFF, panelBufferSize); // start all-white
|
||||
|
||||
const uint16_t pw = driver->width;
|
||||
const uint16_t ph = driver->height;
|
||||
|
||||
// OLEDDisplay buffer: byte = buffer[x + (y/8) * displayWidth]; bit = 1 << (y & 7); 1 = black
|
||||
// Niche buffer: byte = (y * panelRowBytes) + (x/8); bit = 1 << (7 - x%8); 1 = white
|
||||
for (uint16_t oy = 0; oy < displayHeight; oy++) {
|
||||
for (uint16_t ox = 0; ox < displayWidth; ox++) {
|
||||
const uint8_t b = buffer[ox + (oy / 8) * displayWidth];
|
||||
const bool isBlack = b & (1 << (oy & 7));
|
||||
|
||||
uint16_t px, py;
|
||||
switch (rotation) {
|
||||
case 1: // 90° CW: OLED (ox,oy) → panel (pw-1-oy, ox)
|
||||
px = pw - 1 - oy;
|
||||
py = ox;
|
||||
break;
|
||||
case 2: // 180°
|
||||
px = pw - 1 - ox;
|
||||
py = ph - 1 - oy;
|
||||
break;
|
||||
case 3: // 270° CW
|
||||
px = oy;
|
||||
py = ph - 1 - ox;
|
||||
break;
|
||||
case 0:
|
||||
default:
|
||||
px = ox;
|
||||
py = oy;
|
||||
break;
|
||||
}
|
||||
|
||||
if (px >= pw || py >= ph)
|
||||
continue;
|
||||
|
||||
const uint32_t byteNum = (py * panelRowBytes) + (px / 8);
|
||||
const uint8_t bitNum = 7 - (px % 8);
|
||||
if (isBlack)
|
||||
panelBuffer[byteNum] &= ~(1 << bitNum);
|
||||
else
|
||||
panelBuffer[byteNum] |= (1 << bitNum);
|
||||
}
|
||||
}
|
||||
|
||||
// FNV-1a
|
||||
uint32_t h = 2166136261u;
|
||||
for (uint32_t i = 0; i < panelBufferSize; i++) {
|
||||
h ^= panelBuffer[i];
|
||||
h *= 16777619u;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
|
||||
OLEDDisplay adapter that routes BaseUI pixel output to a NicheGraphics::Drivers::EInk driver.
|
||||
|
||||
One adapter serves all E-Ink variants: the panel driver and orientation are injected at construction,
|
||||
and FULL/FAST selection is made by the shared DisplayHealth model (same as InkHUD).
|
||||
|
||||
Replaces the per-board branching in EInkDisplay2 / EInkDynamicDisplay / EInkParallelDisplay.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "graphics/eink/Drivers/EInk.h"
|
||||
|
||||
#include <OLEDDisplay.h>
|
||||
|
||||
namespace NicheGraphics
|
||||
{
|
||||
|
||||
class BaseUIEInkDisplay : public OLEDDisplay
|
||||
{
|
||||
public:
|
||||
// Flags Screen.cpp sets via EINK_ADD_FRAMEFLAG before triggering a draw.
|
||||
// Bits are combined; decided at render time.
|
||||
enum frameFlagTypes : uint8_t {
|
||||
BACKGROUND = (1 << 0), // Regular OLEDDisplayUi tick - no urgency, UNSPECIFIED
|
||||
RESPONSIVE = (1 << 1), // User-driven refresh - prefer FAST
|
||||
COSMETIC = (1 << 2), // Clean splash / wake-from-sleep - force FULL
|
||||
DEMAND_FAST = (1 << 3), // Menu interaction - force FAST
|
||||
BLOCKING = (1 << 4), // Wait for update to finish before returning
|
||||
UNLIMITED_FAST = (1 << 5), // Suppress health-driven FULL promotion (typing modes)
|
||||
};
|
||||
|
||||
BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation);
|
||||
~BaseUIEInkDisplay() override;
|
||||
|
||||
// OLEDDisplay overrides
|
||||
bool connect() override;
|
||||
void display() override;
|
||||
void sendCommand(uint8_t com) override { (void)com; }
|
||||
int getBufferOffset(void) override { return 0; }
|
||||
|
||||
// BaseUI public API (same shape as the old EInkDynamicDisplay)
|
||||
bool forceDisplay(uint32_t msecLimit = 1000);
|
||||
void addFrameFlag(frameFlagTypes flag);
|
||||
void joinAsyncRefresh();
|
||||
void enableUnlimitedFastMode() { addFrameFlag(UNLIMITED_FAST); }
|
||||
void disableUnlimitedFastMode() { frameFlags = (frameFlagTypes)(frameFlags & ~UNLIMITED_FAST); }
|
||||
|
||||
// Tuning, called once per panel profile
|
||||
void setDisplayResilience(uint8_t fastPerFull, float stressMultiplier = 2.0f);
|
||||
|
||||
// Exposed so Screen.cpp / variants can read the rotation passed in at construction
|
||||
uint8_t getRotation() const { return rotation; }
|
||||
|
||||
private:
|
||||
// Perform an update now, unconditionally. Returns true if a frame was pushed to the driver.
|
||||
bool commit(Drivers::EInk::UpdateTypes type, bool blocking);
|
||||
|
||||
// Convert OLEDDisplay's column-major buffer into the panel's row-major MSB-left buffer.
|
||||
// Applies rotation. Returns the hash of the panel buffer for frame-skip comparison.
|
||||
uint32_t repack();
|
||||
|
||||
// Decide FULL vs FAST based on current frame flags + accumulated debt.
|
||||
Drivers::EInk::UpdateTypes decide();
|
||||
|
||||
Drivers::EInk *driver = nullptr;
|
||||
uint8_t rotation = 0; // 0=0°, 1=90°CW, 2=180°, 3=270°CW
|
||||
uint8_t *panelBuffer = nullptr;
|
||||
uint32_t panelBufferSize = 0;
|
||||
uint16_t panelRowBytes = 0;
|
||||
|
||||
frameFlagTypes frameFlags = BACKGROUND;
|
||||
uint32_t lastDrawMsec = 0;
|
||||
uint32_t lastHash = 0;
|
||||
|
||||
// DisplayHealth-style debt tracking
|
||||
float fullRefreshDebt = 0.0f;
|
||||
uint8_t fastPerFull = 7;
|
||||
float stressMultiplier = 2.0f;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics
|
||||
|
||||
// Compat macros used throughout Screen.cpp - route straight to the adapter.
|
||||
#define EINK_ADD_FRAMEFLAG(display, flag) \
|
||||
static_cast<NicheGraphics::BaseUIEInkDisplay *>(display)->addFrameFlag(NicheGraphics::BaseUIEInkDisplay::flag)
|
||||
#define EINK_JOIN_ASYNCREFRESH(display) static_cast<NicheGraphics::BaseUIEInkDisplay *>(display)->joinAsyncRefresh()
|
||||
|
||||
#else // !MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
#define EINK_ADD_FRAMEFLAG(display, flag)
|
||||
#define EINK_JOIN_ASYNCREFRESH(display)
|
||||
#endif
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "configuration.h"
|
||||
|
||||
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
|
||||
#include "EInkDisplay2.h"
|
||||
#include "SPILock.h"
|
||||
#include "main.h"
|
||||
#include <SPI.h>
|
||||
|
||||
#ifdef GXEPD2_DRIVER_0
|
||||
#include "einkDetect.h"
|
||||
#endif
|
||||
|
||||
/*
|
||||
The macros EINK_DISPLAY_MODEL, EINK_WIDTH, and EINK_HEIGHT are defined as build_flags in a variant's platformio.ini
|
||||
Previously, these macros were defined at the top of this file.
|
||||
|
||||
For archival reasons, note that the following configurations had also been tested during this period:
|
||||
* ifdef RAK4631
|
||||
- 4.2 inch
|
||||
EINK_DISPLAY_MODEL: GxEPD2_420_M01
|
||||
EINK_WIDTH: 300
|
||||
EINK_WIDTH: 400
|
||||
|
||||
- 2.9 inch
|
||||
EINK_DISPLAY_MODEL: GxEPD2_290_T5D
|
||||
EINK_WIDTH: 296
|
||||
EINK_HEIGHT: 128
|
||||
|
||||
- 1.54 inch
|
||||
EINK_DISPLAY_MODEL: GxEPD2_154_M09
|
||||
EINK_WIDTH: 200
|
||||
EINK_HEIGHT: 200
|
||||
*/
|
||||
|
||||
// Constructor
|
||||
EInkDisplay::EInkDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
|
||||
{
|
||||
// Set dimensions in OLEDDisplay base class
|
||||
this->geometry = GEOMETRY_RAWMODE;
|
||||
this->displayWidth = EINK_WIDTH;
|
||||
this->displayHeight = EINK_HEIGHT;
|
||||
|
||||
// Round shortest side up to nearest byte, to prevent truncation causing an undersized buffer
|
||||
uint16_t shortSide = min(EINK_WIDTH, EINK_HEIGHT);
|
||||
uint16_t longSide = max(EINK_WIDTH, EINK_HEIGHT);
|
||||
if (shortSide % 8 != 0)
|
||||
shortSide = (shortSide | 7) + 1;
|
||||
|
||||
this->displayBufferSize = longSide * (shortSide / 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a display update if we haven't drawn within the specified msecLimit
|
||||
*/
|
||||
bool EInkDisplay::forceDisplay(uint32_t msecLimit)
|
||||
{
|
||||
// No need to grab this lock because we are on our own SPI bus
|
||||
// concurrency::LockGuard g(spiLock);
|
||||
|
||||
uint32_t now = millis();
|
||||
uint32_t sinceLast = now - lastDrawMsec;
|
||||
|
||||
if (adafruitDisplay && (sinceLast > msecLimit || lastDrawMsec == 0))
|
||||
lastDrawMsec = now;
|
||||
else
|
||||
return false;
|
||||
|
||||
// FIXME - only draw bits have changed (use backbuf similar to the other displays)
|
||||
const bool flipped = config.display.flip_screen;
|
||||
// HACK for L1 EInk
|
||||
#if defined(SEEED_WIO_TRACKER_L1_EINK)
|
||||
// For SEEED_WIO_TRACKER_L1_EINK, setRotation(3) is correct but mirrored; flip both axes
|
||||
for (uint32_t y = 0; y < displayHeight; y++) {
|
||||
for (uint32_t x = 0; x < displayWidth; x++) {
|
||||
auto b = buffer[x + (y / 8) * displayWidth];
|
||||
auto isset = b & (1 << (y & 7));
|
||||
adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE);
|
||||
}
|
||||
}
|
||||
#else
|
||||
for (uint32_t y = 0; y < displayHeight; y++) {
|
||||
for (uint32_t x = 0; x < displayWidth; x++) {
|
||||
auto b = buffer[x + (y / 8) * displayWidth];
|
||||
auto isset = b & (1 << (y & 7));
|
||||
if (flipped)
|
||||
adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE);
|
||||
else
|
||||
adafruitDisplay->drawPixel(x, y, isset ? GxEPD_BLACK : GxEPD_WHITE);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Trigger the refresh in GxEPD2
|
||||
LOG_DEBUG("Update E-Paper");
|
||||
adafruitDisplay->nextPage();
|
||||
|
||||
// End the update process
|
||||
endUpdate();
|
||||
|
||||
LOG_DEBUG("done");
|
||||
return true;
|
||||
}
|
||||
|
||||
// End the update process - virtual method, overridden in derived class
|
||||
void EInkDisplay::endUpdate()
|
||||
{
|
||||
#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
|
||||
void EInkDisplay::display(void)
|
||||
{
|
||||
// We don't allow regular 'dumb' display() calls to draw on eink until we've shown
|
||||
// at least one forceDisplay() keyframe. This prevents flashing when we should the critical
|
||||
// bootscreen (that we want to look nice)
|
||||
|
||||
if (lastDrawMsec) {
|
||||
forceDisplay(slowUpdateMsec); // Show the first screen a few seconds after boot, then slower
|
||||
}
|
||||
}
|
||||
|
||||
// Send a command to the display (low level function)
|
||||
void EInkDisplay::sendCommand(uint8_t com)
|
||||
{
|
||||
(void)com;
|
||||
// Drop all commands to device (we just update the buffer)
|
||||
}
|
||||
|
||||
void EInkDisplay::setDetected(uint8_t detected)
|
||||
{
|
||||
(void)detected;
|
||||
}
|
||||
|
||||
// Connect to the display - variant specific
|
||||
bool EInkDisplay::connect()
|
||||
{
|
||||
LOG_INFO("Do EInk init");
|
||||
|
||||
#ifdef PIN_EINK_EN
|
||||
// backlight power, HIGH is backlight on, LOW is off
|
||||
pinMode(PIN_EINK_EN, OUTPUT);
|
||||
#ifdef ELECROW_ThinkNode_M1
|
||||
// ThinkNode M1 has a hardware dimmable backlight. Start enabled
|
||||
digitalWrite(PIN_EINK_EN, HIGH);
|
||||
#elif defined(MINI_EPAPER_S3)
|
||||
// T-Mini Epaper S3 requires panel power rail enabled before SPI transfer.
|
||||
digitalWrite(PIN_EINK_EN, HIGH);
|
||||
delay(10);
|
||||
#else
|
||||
digitalWrite(PIN_EINK_EN, LOW);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
|
||||
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
adafruitDisplay->init();
|
||||
#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE)
|
||||
adafruitDisplay->setRotation(4);
|
||||
#else
|
||||
adafruitDisplay->setRotation(3);
|
||||
#endif
|
||||
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
|
||||
}
|
||||
#elif defined(ELECROW_ThinkNode_M5)
|
||||
{
|
||||
// Start HSPI
|
||||
hspi = new SPIClass(HSPI);
|
||||
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
|
||||
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
|
||||
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
adafruitDisplay->init();
|
||||
|
||||
adafruitDisplay->setRotation(4);
|
||||
|
||||
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
|
||||
}
|
||||
#elif defined(MESHLINK)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
|
||||
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
adafruitDisplay->init();
|
||||
adafruitDisplay->setRotation(3);
|
||||
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
|
||||
}
|
||||
#elif defined(RAK4630) || defined(MAKERPYTHON)
|
||||
{
|
||||
if (eink_found) {
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
adafruitDisplay->init(115200, true, 10, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
// RAK14000 2.13 inch b/w 250x122 does actually now support fast refresh
|
||||
adafruitDisplay->setRotation(3);
|
||||
// Fast refresh support for 1.54, 2.13 RAK14000 b/w , 2.9 and 4.2
|
||||
// adafruitDisplay->setRotation(1);
|
||||
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
|
||||
} else {
|
||||
(void)adafruitDisplay;
|
||||
}
|
||||
}
|
||||
|
||||
#elif defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || \
|
||||
defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || \
|
||||
defined(MINI_EPAPER_S3)
|
||||
{
|
||||
// Start HSPI
|
||||
hspi = new SPIClass(HSPI);
|
||||
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
|
||||
// VExt already enabled in setup()
|
||||
// RTC GPIO hold disabled in setup()
|
||||
|
||||
// Create GxEPD2 objects
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
|
||||
// Init GxEPD2
|
||||
adafruitDisplay->init();
|
||||
#if defined(MINI_EPAPER_S3)
|
||||
adafruitDisplay->setRotation(3);
|
||||
#else
|
||||
adafruitDisplay->setRotation(3);
|
||||
#if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER)
|
||||
adafruitDisplay->setRotation(0);
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#elif defined(PCA10059) || defined(ME25LS01)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
adafruitDisplay->init(115200, true, 40, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
adafruitDisplay->setRotation(0);
|
||||
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
|
||||
}
|
||||
#elif defined(M5_COREINK) || defined(T_DECK_PRO)
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
adafruitDisplay->setRotation(0);
|
||||
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
|
||||
#elif defined(my) || defined(ESP32_S3_PICO)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
adafruitDisplay->setRotation(1);
|
||||
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
|
||||
}
|
||||
#elif defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK)
|
||||
{
|
||||
spi1 = &SPI1;
|
||||
spi1->begin();
|
||||
// VExt already enabled in setup()
|
||||
// RTC GPIO hold disabled in setup()
|
||||
|
||||
// Create GxEPD2 objects
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
|
||||
// Init GxEPD2
|
||||
adafruitDisplay->init();
|
||||
adafruitDisplay->setRotation(3);
|
||||
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
|
||||
}
|
||||
#elif defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_VISION_MASTER_E213)
|
||||
|
||||
// Detect display model, before starting SPI
|
||||
EInkDetectionResult displayModel = detectEInk();
|
||||
|
||||
// Start HSPI
|
||||
hspi = new SPIClass(HSPI);
|
||||
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
|
||||
|
||||
// Create GxEPD2 object
|
||||
adafruitDisplay = new GxEPD2_Multi<GXEPD2_DRIVER_0, GXEPD2_DRIVER_1>((uint8_t)displayModel, PIN_EINK_CS, PIN_EINK_DC,
|
||||
PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
|
||||
|
||||
// Init GxEPD2
|
||||
adafruitDisplay->init();
|
||||
adafruitDisplay->setRotation(3);
|
||||
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,106 @@
|
||||
#pragma once
|
||||
|
||||
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
|
||||
|
||||
#include "GxEPD2_BW.h"
|
||||
#include <OLEDDisplay.h>
|
||||
|
||||
#ifdef GXEPD2_DRIVER_0 // If variant has multiple possible display models
|
||||
#include "GxEPD2Multi.h"
|
||||
#endif
|
||||
|
||||
// Limit how often we push a full E-Ink refresh. T-Deck Pro needs faster updates for typing.
|
||||
#ifndef EINK_FORCE_DISPLAY_THROTTLE_MS
|
||||
#if defined(T_DECK_PRO)
|
||||
#define EINK_FORCE_DISPLAY_THROTTLE_MS 200
|
||||
#else
|
||||
#define EINK_FORCE_DISPLAY_THROTTLE_MS 1000
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/**
|
||||
* An adapter class that allows using the GxEPD2 library as if it was an OLEDDisplay implementation.
|
||||
*
|
||||
* Note: EInkDynamicDisplay derives from this class.
|
||||
*
|
||||
* Remaining TODO:
|
||||
* optimize display() to only draw changed pixels (see other OLED subclasses for examples)
|
||||
* implement displayOn/displayOff to turn off the TFT device (and backlight)
|
||||
* Use the fast NRF52 SPI API rather than the slow standard arduino version
|
||||
*
|
||||
* turn radio back on - currently with both on spi bus is fucked? or are we leaving chip select asserted?
|
||||
* Suggestion: perhaps similar to HELTEC_WIRELESS_PAPER issue, which resolved with rtc_gpio_hold_dis()
|
||||
*/
|
||||
class EInkDisplay : public OLEDDisplay
|
||||
{
|
||||
/// How often should we update the display
|
||||
/// thereafter we do once per 5 minutes
|
||||
uint32_t slowUpdateMsec = 5 * 60 * 1000;
|
||||
|
||||
public:
|
||||
/* constructor
|
||||
FIXME - the parameters are not used, just a temporary hack to keep working like the old displays
|
||||
*/
|
||||
EInkDisplay(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C);
|
||||
|
||||
// Write the buffer to the display memory (for eink we only do this occasionally)
|
||||
virtual void display(void) override;
|
||||
|
||||
/**
|
||||
* Force a display update if we haven't drawn within the specified msecLimit
|
||||
*
|
||||
* @return true if we did draw the screen
|
||||
*/
|
||||
virtual bool forceDisplay(uint32_t msecLimit = EINK_FORCE_DISPLAY_THROTTLE_MS);
|
||||
|
||||
/**
|
||||
* Run any code needed to complete an update, after the physical refresh has completed.
|
||||
* Split from forceDisplay(), to enable async refresh in derived EInkDynamicDisplay class.
|
||||
*
|
||||
*/
|
||||
virtual void endUpdate();
|
||||
|
||||
/**
|
||||
* shim to make the abstraction happy
|
||||
*
|
||||
*/
|
||||
void setDetected(uint8_t detected);
|
||||
|
||||
protected:
|
||||
// the header size of the buffer used, e.g. for the SPI command header
|
||||
virtual int getBufferOffset(void) override { return 0; }
|
||||
|
||||
// Send a command to the display (low level function)
|
||||
virtual void sendCommand(uint8_t com) override;
|
||||
|
||||
// Connect to the display
|
||||
virtual bool connect() override;
|
||||
|
||||
#ifdef GXEPD2_DRIVER_0
|
||||
// AdafruitGFX display object - wrapper for multiple drivers
|
||||
// Allows runtime detection of multiple displays
|
||||
// Avoid this situation if possible!
|
||||
GxEPD2_Multi<GXEPD2_DRIVER_0, GXEPD2_DRIVER_1> *adafruitDisplay = NULL;
|
||||
#else
|
||||
// AdafruitGFX display object (for single display model) - instantiated in connect(), variant specific
|
||||
GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT> *adafruitDisplay = NULL;
|
||||
#endif
|
||||
|
||||
// If display uses HSPI
|
||||
#if defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E213) || \
|
||||
defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || defined(CROWPANEL_ESP32S3_5_EPAPER) || \
|
||||
defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) || \
|
||||
defined(MINI_EPAPER_S3)
|
||||
SPIClass *hspi = NULL;
|
||||
#endif
|
||||
|
||||
#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK)
|
||||
SPIClass *spi1 = NULL;
|
||||
#endif
|
||||
|
||||
private:
|
||||
// FIXME quick hack to limit drawing to a very slow rate
|
||||
uint32_t lastDrawMsec = 0;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,564 @@
|
||||
#include "Throttle.h"
|
||||
#include "configuration.h"
|
||||
|
||||
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
|
||||
#include "EInkDynamicDisplay.h"
|
||||
|
||||
// Constructor
|
||||
EInkDynamicDisplay::EInkDynamicDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
|
||||
: EInkDisplay(address, sda, scl, geometry, i2cBus), NotifiedWorkerThread("EInkDynamicDisplay")
|
||||
{
|
||||
// If tracking ghost pixels, grab memory
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
dirtyPixels = std::unique_ptr<uint8_t[]>(new uint8_t[EInkDisplay::displayBufferSize]()); // Init with zeros
|
||||
#endif
|
||||
}
|
||||
|
||||
// Destructor
|
||||
EInkDynamicDisplay::~EInkDynamicDisplay()
|
||||
{
|
||||
// If we were tracking ghost pixels, free the memory
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
dirtyPixels = nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Screen requests a BACKGROUND frame
|
||||
void EInkDynamicDisplay::display()
|
||||
{
|
||||
addFrameFlag(BACKGROUND);
|
||||
update();
|
||||
}
|
||||
|
||||
// Screen requests a RESPONSIVE frame
|
||||
bool EInkDynamicDisplay::forceDisplay(uint32_t msecLimit)
|
||||
{
|
||||
addFrameFlag(RESPONSIVE);
|
||||
return update(); // (Unutilized) Base class promises to return true if update ran
|
||||
}
|
||||
|
||||
// Add flag for the next frame
|
||||
void EInkDynamicDisplay::addFrameFlag(frameFlagTypes flag)
|
||||
{
|
||||
// OR the new flag into the existing flags
|
||||
this->frameFlags = (frameFlagTypes)(this->frameFlags | flag);
|
||||
}
|
||||
|
||||
// GxEPD2 code to set fast refresh
|
||||
void EInkDynamicDisplay::configForFastRefresh()
|
||||
{
|
||||
// Variant-specific code can go here
|
||||
#if defined(PRIVATE_HW)
|
||||
#else
|
||||
// Otherwise:
|
||||
adafruitDisplay->setPartialWindow(0, 0, adafruitDisplay->width(), adafruitDisplay->height());
|
||||
#endif
|
||||
}
|
||||
|
||||
// GxEPD2 code to set full refresh
|
||||
void EInkDynamicDisplay::configForFullRefresh()
|
||||
{
|
||||
// Variant-specific code can go here
|
||||
#if defined(PRIVATE_HW)
|
||||
#else
|
||||
// Otherwise:
|
||||
adafruitDisplay->setFullWindow();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Run any relevant GxEPD2 code, so next update will use correct refresh type
|
||||
void EInkDynamicDisplay::applyRefreshMode()
|
||||
{
|
||||
// Change from FULL to FAST
|
||||
if (currentConfig == FULL && refresh == FAST) {
|
||||
configForFastRefresh();
|
||||
currentConfig = FAST;
|
||||
}
|
||||
|
||||
// Change from FAST back to FULL
|
||||
else if (currentConfig == FAST && refresh == FULL) {
|
||||
configForFullRefresh();
|
||||
currentConfig = FULL;
|
||||
}
|
||||
}
|
||||
|
||||
// Update fastRefreshCount
|
||||
void EInkDynamicDisplay::adjustRefreshCounters()
|
||||
{
|
||||
if (refresh == FAST)
|
||||
fastRefreshCount++;
|
||||
|
||||
else if (refresh == FULL)
|
||||
fastRefreshCount = 0;
|
||||
}
|
||||
|
||||
// Trigger the display update by calling base class
|
||||
bool EInkDynamicDisplay::update()
|
||||
{
|
||||
// Determine the refresh mode to use, and start the update
|
||||
bool refreshApproved = determineMode();
|
||||
if (refreshApproved) {
|
||||
EInkDisplay::forceDisplay(0); // Bypass base class' own rate-limiting system
|
||||
storeAndReset(); // Store the result of this loop for next time. Note: call *before* endOrDetach()
|
||||
endOrDetach(); // endUpdate() right now, or set the async refresh flag (if FULL and HAS_EINK_ASYNCFULL)
|
||||
} else
|
||||
storeAndReset(); // No update, no post-update code, just store the results
|
||||
|
||||
return refreshApproved; // (Unutilized) Base class promises to return true if update ran
|
||||
}
|
||||
|
||||
// Figure out who runs the post-update code
|
||||
void EInkDynamicDisplay::endOrDetach()
|
||||
{
|
||||
// If the GxEPD2 version reports that it has the async modifications
|
||||
#ifdef HAS_EINK_ASYNCFULL
|
||||
if (previousRefresh == FULL) {
|
||||
asyncRefreshRunning = true; // Set the flag - checked in determineMode(); cleared by onNotify()
|
||||
|
||||
if (previousFrameFlags & BLOCKING)
|
||||
awaitRefresh();
|
||||
else {
|
||||
// Async begins
|
||||
LOG_DEBUG("Async full-refresh begins (drop frames)");
|
||||
notifyLater(intervalPollAsyncRefresh, DUE_POLL_ASYNCREFRESH, true); // Hand-off to NotifiedWorkerThread
|
||||
}
|
||||
}
|
||||
|
||||
// Fast Refresh
|
||||
else if (previousRefresh == FAST)
|
||||
EInkDisplay::endUpdate(); // Still block while updating, but EInkDisplay needs us to call endUpdate() ourselves.
|
||||
|
||||
// Fallback - If using an unmodified version of GxEPD2 for some reason
|
||||
#else
|
||||
if (previousRefresh == FULL || previousRefresh == FAST) { // If refresh wasn't skipped (on unspecified..)
|
||||
LOG_WARN(
|
||||
"GxEPD2 version has not been modified to support async refresh; using fallback behavior. Please update lib_deps in "
|
||||
"variant's platformio.ini file");
|
||||
EInkDisplay::endUpdate();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Assess situation, pick a refresh type
|
||||
bool EInkDynamicDisplay::determineMode()
|
||||
{
|
||||
checkInitialized();
|
||||
checkForPromotion();
|
||||
#if defined(HAS_EINK_ASYNCFULL)
|
||||
checkBusyAsyncRefresh();
|
||||
#endif
|
||||
checkRateLimiting();
|
||||
|
||||
// If too soon for a new frame, or display busy, abort early
|
||||
if (refresh == SKIPPED)
|
||||
return false; // No refresh
|
||||
|
||||
// -- New frame is due --
|
||||
|
||||
resetRateLimiting(); // Once determineMode() ends, will have to wait again
|
||||
hashImage(); // Generate here, so we can still copy it to previousImageHash, even if we skip the comparison check
|
||||
LOG_DEBUG("determineMode(): "); // Begin log entry
|
||||
|
||||
// Once mode determined, any remaining checks will bypass
|
||||
checkCosmetic();
|
||||
checkDemandingFast();
|
||||
checkFrameMatchesPrevious();
|
||||
checkConsecutiveFastRefreshes();
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
checkExcessiveGhosting();
|
||||
#endif
|
||||
checkFastRequested();
|
||||
|
||||
if (refresh == UNSPECIFIED)
|
||||
LOG_WARN("There was a flaw in the determineMode() logic");
|
||||
|
||||
// -- Decision has been reached --
|
||||
applyRefreshMode();
|
||||
adjustRefreshCounters();
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// Full refresh clears any ghosting
|
||||
if (refresh == FULL)
|
||||
resetGhostPixelTracking();
|
||||
#endif
|
||||
|
||||
// Return - call a refresh or not?
|
||||
if (refresh == SKIPPED)
|
||||
return false; // Don't trigger a refresh
|
||||
else
|
||||
return true; // Do trigger a refresh
|
||||
}
|
||||
|
||||
// Is this the very first frame?
|
||||
void EInkDynamicDisplay::checkInitialized()
|
||||
{
|
||||
if (!initialized) {
|
||||
// Undo GxEPD2_BW::partialWindow(), if set by developer in EInkDisplay::connect()
|
||||
configForFullRefresh();
|
||||
|
||||
// Clear any existing image, so we can draw logo with fast-refresh, but also to set GxEPD2_EPD::_initial_write
|
||||
adafruitDisplay->clearScreen();
|
||||
|
||||
LOG_DEBUG("initialized, ");
|
||||
initialized = true;
|
||||
|
||||
// Use a fast-refresh for the next frame; no skipping or else blank screen when waking from deep sleep
|
||||
addFrameFlag(DEMAND_FAST);
|
||||
}
|
||||
}
|
||||
|
||||
// Was a frame skipped (rate, display busy) that should have been a FAST refresh?
|
||||
void EInkDynamicDisplay::checkForPromotion()
|
||||
{
|
||||
// If a frame was skipped (rate, display busy), then promote a BACKGROUND frame
|
||||
// Because we DID want a RESPONSIVE/COSMETIC/DEMAND_FULL frame last time, we just didn't get it
|
||||
|
||||
switch (previousReason) {
|
||||
case ASYNC_REFRESH_BLOCKED_DEMANDFAST:
|
||||
addFrameFlag(DEMAND_FAST);
|
||||
break;
|
||||
case ASYNC_REFRESH_BLOCKED_COSMETIC:
|
||||
addFrameFlag(COSMETIC);
|
||||
break;
|
||||
case ASYNC_REFRESH_BLOCKED_RESPONSIVE:
|
||||
case EXCEEDED_RATELIMIT_FAST:
|
||||
addFrameFlag(RESPONSIVE);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Is it too soon for another frame of this type?
|
||||
void EInkDynamicDisplay::checkRateLimiting()
|
||||
{
|
||||
// Sanity check: millis() overflow - just let the update run..
|
||||
if (previousRunMs > millis())
|
||||
return;
|
||||
|
||||
// Skip update: too soon for BACKGROUND
|
||||
if (frameFlags == BACKGROUND) {
|
||||
if (Throttle::isWithinTimespanMs(previousRunMs, 30000)) {
|
||||
refresh = SKIPPED;
|
||||
reason = EXCEEDED_RATELIMIT_FULL;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No rate-limit for these special cases
|
||||
if (frameFlags & COSMETIC || frameFlags & DEMAND_FAST)
|
||||
return;
|
||||
|
||||
// Skip update: too soon for RESPONSIVE
|
||||
if (frameFlags & RESPONSIVE) {
|
||||
if (Throttle::isWithinTimespanMs(previousRunMs, 1000)) {
|
||||
refresh = SKIPPED;
|
||||
reason = EXCEEDED_RATELIMIT_FAST;
|
||||
LOG_DEBUG("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is this frame COSMETIC (splash screens?)
|
||||
void EInkDynamicDisplay::checkCosmetic()
|
||||
{
|
||||
// If a decision was already reached, don't run the check
|
||||
if (refresh != UNSPECIFIED)
|
||||
return;
|
||||
|
||||
// A full refresh is requested for cosmetic purposes: we have a decision
|
||||
if (frameFlags & COSMETIC) {
|
||||
refresh = FULL;
|
||||
reason = FLAGGED_COSMETIC;
|
||||
LOG_DEBUG("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
// Is this a one-off special circumstance, where we REALLY want a fast refresh?
|
||||
void EInkDynamicDisplay::checkDemandingFast()
|
||||
{
|
||||
// If a decision was already reached, don't run the check
|
||||
if (refresh != UNSPECIFIED)
|
||||
return;
|
||||
|
||||
// A fast refresh is demanded: we have a decision
|
||||
if (frameFlags & DEMAND_FAST) {
|
||||
refresh = FAST;
|
||||
reason = FLAGGED_DEMAND_FAST;
|
||||
LOG_DEBUG("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
// Does the new frame match the currently displayed image?
|
||||
void EInkDynamicDisplay::checkFrameMatchesPrevious()
|
||||
{
|
||||
// If a decision was already reached, don't run the check
|
||||
if (refresh != UNSPECIFIED)
|
||||
return;
|
||||
|
||||
// If frame is *not* a duplicate, abort the check
|
||||
if (imageHash != previousImageHash)
|
||||
return;
|
||||
|
||||
#if !defined(EINK_BACKGROUND_USES_FAST)
|
||||
// If BACKGROUND, and last update was FAST: redraw the same image in FULL (for display health + image quality)
|
||||
if (frameFlags == BACKGROUND && fastRefreshCount > 0) {
|
||||
refresh = FULL;
|
||||
reason = REDRAW_WITH_FULL;
|
||||
LOG_DEBUG("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Not redrawn, not COSMETIC, not DEMAND_FAST
|
||||
refresh = SKIPPED;
|
||||
reason = FRAME_MATCHED_PREVIOUS;
|
||||
LOG_DEBUG("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
|
||||
// Have too many fast-refreshes occurred consecutively, since last full refresh?
|
||||
void EInkDynamicDisplay::checkConsecutiveFastRefreshes()
|
||||
{
|
||||
// If a decision was already reached, don't run the check
|
||||
if (refresh != UNSPECIFIED)
|
||||
return;
|
||||
|
||||
// Bypass limit if UNLIMITED_FAST mode is active
|
||||
if (frameFlags & UNLIMITED_FAST) {
|
||||
refresh = FAST;
|
||||
reason = NO_OBJECTIONS;
|
||||
LOG_DEBUG("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags);
|
||||
return;
|
||||
}
|
||||
|
||||
// If too many FAST refreshes consecutively - force a FULL refresh
|
||||
if (fastRefreshCount >= EINK_LIMIT_FASTREFRESH) {
|
||||
refresh = FULL;
|
||||
reason = EXCEEDED_LIMIT_FASTREFRESH;
|
||||
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
// No objections, we can perform fast-refresh, if desired
|
||||
void EInkDynamicDisplay::checkFastRequested()
|
||||
{
|
||||
if (refresh != UNSPECIFIED)
|
||||
return;
|
||||
|
||||
if (frameFlags == BACKGROUND) {
|
||||
#ifdef EINK_BACKGROUND_USES_FAST
|
||||
// If we want BACKGROUND to use fast. (FULL only when a limit is hit)
|
||||
refresh = FAST;
|
||||
reason = BACKGROUND_USES_FAST;
|
||||
LOG_DEBUG("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount,
|
||||
frameFlags);
|
||||
#else
|
||||
// If we do want to use FULL for BACKGROUND updates
|
||||
refresh = FULL;
|
||||
reason = FLAGGED_BACKGROUND;
|
||||
LOG_DEBUG("refresh=FULL, reason=FLAGGED_BACKGROUND");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Sanity: confirm that we did ask for a RESPONSIVE frame.
|
||||
if (frameFlags & RESPONSIVE) {
|
||||
refresh = FAST;
|
||||
reason = NO_OBJECTIONS;
|
||||
LOG_DEBUG("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the timer used for rate-limiting
|
||||
void EInkDynamicDisplay::resetRateLimiting()
|
||||
{
|
||||
previousRunMs = millis();
|
||||
}
|
||||
|
||||
// Generate a hash of this frame, to compare against previous update
|
||||
void EInkDynamicDisplay::hashImage()
|
||||
{
|
||||
imageHash = 0;
|
||||
|
||||
// Sum all bytes of the image buffer together
|
||||
for (uint16_t b = 0; b < (displayWidth / 8) * displayHeight; b++) {
|
||||
imageHash ^= buffer[b] << b;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the results of determineMode() for future use, and reset for next call
|
||||
void EInkDynamicDisplay::storeAndReset()
|
||||
{
|
||||
previousFrameFlags = frameFlags;
|
||||
previousRefresh = refresh;
|
||||
previousReason = reason;
|
||||
|
||||
// Only store image hash if the display will update
|
||||
if (refresh != SKIPPED) {
|
||||
previousImageHash = imageHash;
|
||||
}
|
||||
|
||||
frameFlags = BACKGROUND;
|
||||
refresh = UNSPECIFIED;
|
||||
}
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// Count how many ghost pixels the new image will display
|
||||
void EInkDynamicDisplay::countGhostPixels()
|
||||
{
|
||||
// If a decision was already reached, don't run the check
|
||||
if (refresh != UNSPECIFIED)
|
||||
return;
|
||||
|
||||
// Start a new count
|
||||
ghostPixelCount = 0;
|
||||
|
||||
// Check new image, bit by bit, for any white pixels at locations marked "dirty"
|
||||
for (uint16_t i = 0; i < displayBufferSize; i++) {
|
||||
for (uint8_t bit = 0; bit < 7; bit++) {
|
||||
|
||||
const bool dirty = (dirtyPixels[i] >> bit) & 1; // Has pixel location been drawn to since full-refresh?
|
||||
const bool shouldBeBlank = !((buffer[i] >> bit) & 1); // Is pixel location white in the new image?
|
||||
|
||||
// If pixel is (or has been) black since last full-refresh, and now is white: ghosting
|
||||
if (dirty && shouldBeBlank)
|
||||
ghostPixelCount++;
|
||||
|
||||
// Update the dirty status for this pixel - will this location become a ghost if set white in future?
|
||||
if (!dirty && !shouldBeBlank)
|
||||
dirtyPixels[i] |= (1 << bit);
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DEBUG("ghostPixels=%hu, ", ghostPixelCount);
|
||||
}
|
||||
|
||||
// Check if ghost pixel count exceeds the defined limit
|
||||
void EInkDynamicDisplay::checkExcessiveGhosting()
|
||||
{
|
||||
// If a decision was already reached, don't run the check
|
||||
if (refresh != UNSPECIFIED)
|
||||
return;
|
||||
|
||||
countGhostPixels();
|
||||
|
||||
// If too many ghost pixels, select full refresh
|
||||
if (ghostPixelCount > EINK_LIMIT_GHOSTING_PX) {
|
||||
refresh = FULL;
|
||||
reason = EXCEEDED_GHOSTINGLIMIT;
|
||||
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the dirty pixels array. Call when full-refresh cleans the display.
|
||||
void EInkDynamicDisplay::resetGhostPixelTracking()
|
||||
{
|
||||
// Copy the current frame into dirtyPixels[] from the display buffer
|
||||
memcpy(dirtyPixels.get(), EInkDisplay::buffer, EInkDisplay::displayBufferSize);
|
||||
}
|
||||
#endif // EINK_LIMIT_GHOSTING_PX
|
||||
|
||||
// Handle any asyc tasks
|
||||
void EInkDynamicDisplay::onNotify(uint32_t notification)
|
||||
{
|
||||
// Which task
|
||||
switch (notification) {
|
||||
case DUE_POLL_ASYNCREFRESH:
|
||||
pollAsyncRefresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef HAS_EINK_ASYNCFULL
|
||||
// Public: wait for an refresh already in progress, then run the post-update code. See Screen::setScreensaverFrames()
|
||||
void EInkDynamicDisplay::joinAsyncRefresh()
|
||||
{
|
||||
// If no async refresh running, nothing to do
|
||||
if (!asyncRefreshRunning)
|
||||
return;
|
||||
|
||||
LOG_DEBUG("Join an async refresh in progress");
|
||||
|
||||
// Continually poll the BUSY pin
|
||||
while (adafruitDisplay->epd2.isBusy())
|
||||
yield();
|
||||
|
||||
// If asyncRefreshRunning flag is still set, but display's BUSY pin reports the refresh is done
|
||||
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
|
||||
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
|
||||
asyncRefreshRunning = false; // Unset the flag
|
||||
LOG_DEBUG("Refresh complete");
|
||||
|
||||
// Note: this code only works because of a modification to meshtastic/GxEPD2.
|
||||
// It is only equipped to intercept calls to nextPage()
|
||||
}
|
||||
|
||||
// Called from NotifiedWorkerThread. Run the post-update code if the hardware is ready
|
||||
void EInkDynamicDisplay::pollAsyncRefresh()
|
||||
{
|
||||
// In theory, this condition should never be met
|
||||
if (!asyncRefreshRunning)
|
||||
return;
|
||||
|
||||
// Still running, check back later
|
||||
if (adafruitDisplay->epd2.isBusy()) {
|
||||
// Schedule next call of pollAsyncRefresh()
|
||||
NotifiedWorkerThread::notifyLater(intervalPollAsyncRefresh, DUE_POLL_ASYNCREFRESH, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If asyncRefreshRunning flag is still set, but display's BUSY pin reports the refresh is done
|
||||
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
|
||||
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
|
||||
asyncRefreshRunning = false; // Unset the flag
|
||||
LOG_DEBUG("Async full-refresh complete");
|
||||
|
||||
// Note: this code only works because of a modification to meshtastic/GxEPD2.
|
||||
// It is only equipped to intercept calls to nextPage()
|
||||
}
|
||||
|
||||
// Check the status of "async full-refresh"; skip if running
|
||||
void EInkDynamicDisplay::checkBusyAsyncRefresh()
|
||||
{
|
||||
// No refresh taking place, continue with determineMode()
|
||||
if (!asyncRefreshRunning)
|
||||
return;
|
||||
|
||||
// Full refresh still running
|
||||
if (adafruitDisplay->epd2.isBusy()) {
|
||||
// No refresh
|
||||
refresh = SKIPPED;
|
||||
|
||||
// Set the reason, marking what type of frame we're skipping
|
||||
if (frameFlags & DEMAND_FAST)
|
||||
reason = ASYNC_REFRESH_BLOCKED_DEMANDFAST;
|
||||
else if (frameFlags & COSMETIC)
|
||||
reason = ASYNC_REFRESH_BLOCKED_COSMETIC;
|
||||
else if (frameFlags & RESPONSIVE)
|
||||
reason = ASYNC_REFRESH_BLOCKED_RESPONSIVE;
|
||||
else
|
||||
reason = ASYNC_REFRESH_BLOCKED_BACKGROUND;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Async refresh appears to have stopped, but wasn't caught by onNotify()
|
||||
else
|
||||
pollAsyncRefresh(); // Check (and terminate) the async refresh manually
|
||||
}
|
||||
|
||||
// Hold control while an async refresh runs
|
||||
void EInkDynamicDisplay::awaitRefresh()
|
||||
{
|
||||
// Continually poll the BUSY pin
|
||||
while (adafruitDisplay->epd2.isBusy())
|
||||
yield();
|
||||
|
||||
// End the full-refresh process
|
||||
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
|
||||
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
|
||||
asyncRefreshRunning = false; // Unset the flag
|
||||
}
|
||||
#endif // HAS_EINK_ASYNCFULL
|
||||
|
||||
#endif // USE_EINK_DYNAMICDISPLAY
|
||||
@@ -0,0 +1,155 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
#include <memory>
|
||||
|
||||
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
|
||||
|
||||
#include "EInkDisplay2.h"
|
||||
#include "GxEPD2_BW.h"
|
||||
#include "concurrency/NotifiedWorkerThread.h"
|
||||
|
||||
/*
|
||||
Derives from the EInkDisplay adapter class.
|
||||
Accepts suggestions from Screen class about frame type.
|
||||
Determines which refresh type is most suitable.
|
||||
(Full, Fast, Skip)
|
||||
*/
|
||||
|
||||
class EInkDynamicDisplay : public EInkDisplay, protected concurrency::NotifiedWorkerThread
|
||||
{
|
||||
public:
|
||||
// Constructor
|
||||
// ( Parameters unused, passed to EInkDisplay. Maintains compatibility OLEDDisplay class )
|
||||
EInkDynamicDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus);
|
||||
~EInkDynamicDisplay();
|
||||
|
||||
// Methods to enable or disable unlimited fast refresh mode
|
||||
void enableUnlimitedFastMode() { addFrameFlag(UNLIMITED_FAST); }
|
||||
void disableUnlimitedFastMode() { frameFlags = (frameFlagTypes)(frameFlags & ~UNLIMITED_FAST); }
|
||||
|
||||
// What kind of frame is this
|
||||
enum frameFlagTypes : uint8_t {
|
||||
BACKGROUND = (1 << 0), // For frames via display()
|
||||
RESPONSIVE = (1 << 1), // For frames via forceDisplay()
|
||||
COSMETIC = (1 << 2), // For splashes
|
||||
DEMAND_FAST = (1 << 3), // Special case only
|
||||
BLOCKING = (1 << 4), // Modifier - block while refresh runs
|
||||
UNLIMITED_FAST = (1 << 5)
|
||||
};
|
||||
void addFrameFlag(frameFlagTypes flag);
|
||||
|
||||
// Set the correct frame flag, then call universal "update()" method
|
||||
void display() override;
|
||||
bool forceDisplay(uint32_t msecLimit) override; // Shadows base class. Parameter and return val unused.
|
||||
|
||||
protected:
|
||||
enum refreshTypes : uint8_t { // Which refresh operation will be used
|
||||
UNSPECIFIED,
|
||||
FULL,
|
||||
FAST,
|
||||
SKIPPED,
|
||||
};
|
||||
enum reasonTypes : uint8_t { // How was the decision reached
|
||||
NO_OBJECTIONS,
|
||||
ASYNC_REFRESH_BLOCKED_DEMANDFAST,
|
||||
ASYNC_REFRESH_BLOCKED_COSMETIC,
|
||||
ASYNC_REFRESH_BLOCKED_RESPONSIVE,
|
||||
ASYNC_REFRESH_BLOCKED_BACKGROUND,
|
||||
EXCEEDED_RATELIMIT_FAST,
|
||||
EXCEEDED_RATELIMIT_FULL,
|
||||
FLAGGED_COSMETIC,
|
||||
FLAGGED_DEMAND_FAST,
|
||||
EXCEEDED_LIMIT_FASTREFRESH,
|
||||
EXCEEDED_GHOSTINGLIMIT,
|
||||
FRAME_MATCHED_PREVIOUS,
|
||||
BACKGROUND_USES_FAST,
|
||||
FLAGGED_BACKGROUND,
|
||||
REDRAW_WITH_FULL,
|
||||
};
|
||||
|
||||
enum notificationTypes : uint8_t { // What was onNotify() called for
|
||||
NONE = 0, // This behavior (NONE=0) is fixed by NotifiedWorkerThread class
|
||||
DUE_POLL_ASYNCREFRESH = 1,
|
||||
};
|
||||
const uint32_t intervalPollAsyncRefresh = 100;
|
||||
|
||||
void onNotify(uint32_t notification) override; // Handle any async tasks - overrides NotifiedWorkerThread
|
||||
void configForFastRefresh(); // GxEPD2 code to set fast-refresh
|
||||
void configForFullRefresh(); // GxEPD2 code to set full-refresh
|
||||
bool determineMode(); // Assess situation, pick a refresh type
|
||||
void applyRefreshMode(); // Run any relevant GxEPD2 code, so next update will use correct refresh type
|
||||
void adjustRefreshCounters(); // Update fastRefreshCount
|
||||
bool update(); // Trigger the display update - determine mode, then call base class
|
||||
void endOrDetach(); // Run the post-update code, or delegate it off to checkBusyAsyncRefresh()
|
||||
|
||||
// Checks as part of determineMode()
|
||||
void checkInitialized(); // Is this the very first frame?
|
||||
void checkForPromotion(); // Was a frame skipped (rate, display busy) that should have been a FAST refresh?
|
||||
void checkRateLimiting(); // Is this frame too soon?
|
||||
void checkCosmetic(); // Was the COSMETIC flag set?
|
||||
void checkDemandingFast(); // Was the DEMAND_FAST flag set?
|
||||
void checkFrameMatchesPrevious(); // Does the new frame match the existing display image?
|
||||
void checkConsecutiveFastRefreshes(); // Too many fast-refreshes consecutively?
|
||||
void checkFastRequested(); // Was the flag set for RESPONSIVE, or only BACKGROUND?
|
||||
|
||||
void resetRateLimiting(); // Set previousRunMs - this now counts as an update, for rate-limiting
|
||||
void hashImage(); // Generate a hashed version of this frame, to compare against previous update
|
||||
void storeAndReset(); // Keep results of determineMode() for later, tidy-up for next call
|
||||
|
||||
// What we are determining for this frame
|
||||
frameFlagTypes frameFlags = BACKGROUND; // Frame characteristics - determineMode() input
|
||||
refreshTypes refresh = UNSPECIFIED; // Refresh type - determineMode() output
|
||||
reasonTypes reason = NO_OBJECTIONS; // Reason - why was refresh type used
|
||||
|
||||
// What happened last time determineMode() ran
|
||||
frameFlagTypes previousFrameFlags = BACKGROUND; // (Previous) Frame flags
|
||||
refreshTypes previousRefresh = UNSPECIFIED; // (Previous) Outcome
|
||||
reasonTypes previousReason = NO_OBJECTIONS; // (Previous) Reason
|
||||
|
||||
bool initialized = false; // Have we drawn at least one frame yet?
|
||||
uint32_t previousRunMs = -1; // When did determineMode() last run (rather than rejecting for rate-limiting)
|
||||
uint32_t imageHash = 0; // Hash of the current frame. Don't bother updating if nothing has changed!
|
||||
uint32_t previousImageHash = 0; // Hash of the previous update's frame
|
||||
uint32_t fastRefreshCount = 0; // How many fast-refreshes consecutively since last full refresh?
|
||||
refreshTypes currentConfig = FULL; // Which refresh type is GxEPD2 currently configured for
|
||||
|
||||
// Optional - track ghosting, pixel by pixel
|
||||
// May 2024: no longer used by any display. Kept for possible future use.
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
void countGhostPixels(); // Count any pixels which have moved from black to white since last full-refresh
|
||||
void checkExcessiveGhosting(); // Check if ghosting exceeds defined limit
|
||||
void resetGhostPixelTracking(); // Clear the dirty pixels array. Call when full-refresh cleans the display.
|
||||
std::unique_ptr<uint8_t[]> dirtyPixels; // Any pixels that have been black since last full-refresh (dynamically allocated mem)
|
||||
uint32_t ghostPixelCount = 0; // Number of pixels with problematic ghosting. Retained here for LOG_DEBUG use
|
||||
#endif
|
||||
|
||||
// Conditional - async full refresh - only with modified meshtastic/GxEPD2
|
||||
#if defined(HAS_EINK_ASYNCFULL)
|
||||
public:
|
||||
void joinAsyncRefresh(); // Main thread joins an async refresh already in progress. Blocks, then runs post-update code
|
||||
|
||||
protected:
|
||||
void pollAsyncRefresh(); // Run the post-update code if the hardware is ready
|
||||
void checkBusyAsyncRefresh(); // Check if display is busy running an async full-refresh (rejecting new frames)
|
||||
void awaitRefresh(); // Hold control while an async refresh runs
|
||||
void endUpdate() override {} // Disable base-class behavior of running post-update immediately after forceDisplay()
|
||||
bool asyncRefreshRunning = false; // Flag, checked by checkBusyAsyncRefresh()
|
||||
#else
|
||||
public:
|
||||
void joinAsyncRefresh() {} // Dummy method
|
||||
|
||||
protected:
|
||||
void pollAsyncRefresh() {} // Dummy method. In theory, not reachable
|
||||
#endif
|
||||
};
|
||||
|
||||
// Hide the ugly casts used in Screen.cpp
|
||||
#define EINK_ADD_FRAMEFLAG(display, flag) static_cast<EInkDynamicDisplay *>(display)->addFrameFlag(EInkDynamicDisplay::flag)
|
||||
#define EINK_JOIN_ASYNCREFRESH(display) static_cast<EInkDynamicDisplay *>(display)->joinAsyncRefresh()
|
||||
|
||||
#else // !USE_EINK_DYNAMICDISPLAY
|
||||
// Dummy-macro, removes the need for include guards
|
||||
#define EINK_ADD_FRAMEFLAG(display, flag)
|
||||
#define EINK_JOIN_ASYNCREFRESH(display)
|
||||
#endif
|
||||
@@ -0,0 +1,427 @@
|
||||
#include "EInkParallelDisplay.h"
|
||||
|
||||
#ifdef USE_EINK_PARALLELDISPLAY
|
||||
|
||||
#include "Wire.h"
|
||||
#include "variant.h"
|
||||
#include <Arduino.h>
|
||||
#include <atomic>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "FastEPD.h"
|
||||
|
||||
// Thresholds for choosing partial vs full update
|
||||
#ifndef EPD_PARTIAL_THRESHOLD_ROWS
|
||||
#define EPD_PARTIAL_THRESHOLD_ROWS 128 // if changed region <= this many rows, prefer partial
|
||||
#endif
|
||||
#ifndef EPD_FULLSLOW_PERIOD
|
||||
#define EPD_FULLSLOW_PERIOD 100 // every N full updates do a slow (CLEAR_SLOW) full refresh
|
||||
#endif
|
||||
#ifndef EPD_RESPONSIVE_MIN_MS
|
||||
#define EPD_RESPONSIVE_MIN_MS 1000 // simple rate-limit (ms) for responsive updates
|
||||
#endif
|
||||
|
||||
EInkParallelDisplay::EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rot) : epaper(nullptr), rotation(rot)
|
||||
{
|
||||
LOG_INFO("init EInkParallelDisplay");
|
||||
// Set dimensions in OLEDDisplay base class
|
||||
this->geometry = GEOMETRY_RAWMODE;
|
||||
this->displayWidth = width;
|
||||
this->displayHeight = height;
|
||||
|
||||
// Round shortest side up to nearest byte, to prevent truncation causing an undersized buffer
|
||||
uint16_t shortSide = min(width, height);
|
||||
uint16_t longSide = max(width, height);
|
||||
if (shortSide % 8 != 0)
|
||||
shortSide = (shortSide | 7) + 1;
|
||||
|
||||
this->displayBufferSize = longSide * (shortSide / 8);
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// allocate dirty pixel buffer same size as epaper buffers (rowBytes * height)
|
||||
size_t rowBytes = (this->displayWidth + 7) / 8;
|
||||
dirtyPixelsSize = rowBytes * this->displayHeight;
|
||||
dirtyPixels = (uint8_t *)calloc(dirtyPixelsSize, 1);
|
||||
ghostPixelCount = 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
EInkParallelDisplay::~EInkParallelDisplay()
|
||||
{
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
if (dirtyPixels) {
|
||||
free(dirtyPixels);
|
||||
dirtyPixels = nullptr;
|
||||
}
|
||||
#endif
|
||||
// If an async full update is running, wait for it to finish
|
||||
if (asyncFullRunning.load()) {
|
||||
// wait a short while for task to finish
|
||||
for (int i = 0; i < 50 && asyncFullRunning.load(); ++i) {
|
||||
delay(50);
|
||||
}
|
||||
if (asyncTaskHandle) {
|
||||
// Let it finish or delete it
|
||||
vTaskDelete(asyncTaskHandle);
|
||||
asyncTaskHandle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
delete epaper;
|
||||
}
|
||||
|
||||
/*
|
||||
* Called by the OLEDDisplay::init() path.
|
||||
*/
|
||||
bool EInkParallelDisplay::connect()
|
||||
{
|
||||
LOG_INFO("Do EPD init");
|
||||
if (!epaper) {
|
||||
epaper = new FASTEPD;
|
||||
#if defined(T5_S3_EPAPER_PRO_V1)
|
||||
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
|
||||
#elif defined(T5_S3_EPAPER_PRO_V2)
|
||||
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
|
||||
// initialize all port 0 pins (0-7) as outputs / HIGH
|
||||
for (int i = 0; i < 8; i++) {
|
||||
epaper->ioPinMode(i, OUTPUT);
|
||||
epaper->ioWrite(i, HIGH);
|
||||
}
|
||||
#else
|
||||
#error "unsupported EPD device!"
|
||||
#endif
|
||||
}
|
||||
|
||||
// epaper->setRotation(rotation); // does not work, messes up width/height
|
||||
epaper->setMode(BB_MODE_1BPP);
|
||||
epaper->clearWhite();
|
||||
epaper->fullUpdate(true);
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// After a full/clear the dirty tracking should be reset
|
||||
resetGhostPixelTracking();
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* sendCommand - simple passthrough (not required for epd_driver-based path)
|
||||
*/
|
||||
void EInkParallelDisplay::sendCommand(uint8_t com)
|
||||
{
|
||||
LOG_DEBUG("EInkParallelDisplay::sendCommand %d", (int)com);
|
||||
}
|
||||
|
||||
/*
|
||||
* Start a background task that will perform a blocking fullUpdate(). This lets
|
||||
* display() return quickly while the heavy refresh runs in the background.
|
||||
*/
|
||||
void EInkParallelDisplay::startAsyncFullUpdate(int clearMode)
|
||||
{
|
||||
if (asyncFullRunning.load())
|
||||
return; // already running
|
||||
|
||||
asyncFullRunning.store(true);
|
||||
// pass 'this' as parameter
|
||||
BaseType_t rc = xTaskCreatePinnedToCore(EInkParallelDisplay::asyncFullUpdateTask, "epd_full", 4096 / sizeof(StackType_t),
|
||||
this, 2, &asyncTaskHandle,
|
||||
#if CONFIG_FREERTOS_UNICORE
|
||||
0
|
||||
#else
|
||||
1
|
||||
#endif
|
||||
);
|
||||
if (rc != pdPASS) {
|
||||
LOG_WARN("Failed to create async full-update task, falling back to blocking update");
|
||||
epaper->fullUpdate(clearMode, false);
|
||||
epaper->backupPlane();
|
||||
asyncFullRunning.store(false);
|
||||
asyncTaskHandle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* FreeRTOS task entry: runs the full update and then backs up plane.
|
||||
*/
|
||||
void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters)
|
||||
{
|
||||
EInkParallelDisplay *self = static_cast<EInkParallelDisplay *>(pvParameters);
|
||||
if (!self) {
|
||||
vTaskDelete(nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// choose CLEAR_SLOW occasionally
|
||||
int clearMode = CLEAR_FAST;
|
||||
if (self->fastRefreshCount >= EPD_FULLSLOW_PERIOD) {
|
||||
clearMode = CLEAR_SLOW;
|
||||
self->fastRefreshCount = 0;
|
||||
} else {
|
||||
// when running async full, treat it as a full so reset fast count
|
||||
self->fastRefreshCount = 0;
|
||||
}
|
||||
|
||||
self->epaper->fullUpdate(clearMode, false);
|
||||
self->epaper->backupPlane();
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// A full refresh clears ghosting state
|
||||
self->resetGhostPixelTracking();
|
||||
#endif
|
||||
|
||||
self->asyncFullRunning.store(false);
|
||||
self->asyncTaskHandle = nullptr;
|
||||
|
||||
// delete this task
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
/*
|
||||
* Convert the OLEDDisplay buffer (vertical byte layout) into the 1bpp horizontal-bytes
|
||||
* buffer used by the FASTEPD library. For performance we write directly into FASTEPD's
|
||||
* currentBuffer() while comparing against previousBuffer() to detect changed rows.
|
||||
* After conversion we call FASTEPD::partialUpdate() or FASTEPD::fullUpdate() according
|
||||
* to a heuristic so only the minimal region is refreshed.
|
||||
*/
|
||||
void EInkParallelDisplay::display(void)
|
||||
{
|
||||
const uint16_t w = this->displayWidth;
|
||||
const uint16_t h = this->displayHeight;
|
||||
|
||||
// Simple rate limiting: avoid very-frequent responsive updates
|
||||
uint32_t nowMs = millis();
|
||||
if (lastUpdateMs != 0 && (nowMs - lastUpdateMs) < EPD_RESPONSIVE_MIN_MS) {
|
||||
LOG_DEBUG("rate-limited, skipping update");
|
||||
return;
|
||||
}
|
||||
|
||||
// bytes per row in epd format (one byte = 8 horizontal pixels)
|
||||
const uint32_t rowBytes = (w + 7) / 8;
|
||||
|
||||
// Get pointers to internal buffers
|
||||
uint8_t *cur = epaper->currentBuffer();
|
||||
const uint8_t *prev = epaper->previousBuffer(); // may be NULL on first init
|
||||
|
||||
// Track changed row range while converting
|
||||
int newTop = h; // min changed row (initialized to out-of-range)
|
||||
int newBottom = -1; // max changed row
|
||||
|
||||
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
|
||||
// Track changed byte column range (for clipped fullUpdate fallback)
|
||||
int newLeftByte = (int)rowBytes;
|
||||
int newRightByte = -1;
|
||||
#endif
|
||||
|
||||
// Compute a quick hash of the incoming OLED buffer (so we can skip identical frames)
|
||||
uint32_t imageHash = 0;
|
||||
uint32_t bufBytes = (w / 8) * h; // vertical-byte layout size
|
||||
for (uint32_t bi = 0; bi < bufBytes; ++bi) {
|
||||
imageHash ^= ((uint32_t)buffer[bi]) << (bi & 31);
|
||||
}
|
||||
if (imageHash == previousImageHash) {
|
||||
// LOG_DEBUG("image identical to previous, skipping update");
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// reset ghost count for this conversion pass; we'll mark bits that change
|
||||
ghostPixelCount = 0;
|
||||
#endif
|
||||
|
||||
// Convert: OLED buffer layout -> FASTEPD 1bpp horizontal-bytes layout into cur,
|
||||
// comparing against prev when available to detect changes.
|
||||
for (uint32_t y = 0; y < h; ++y) {
|
||||
const uint32_t base = (y >> 3) * w; // (y/8) * width
|
||||
const uint8_t bitMask = (uint8_t)(1u << (y & 7)); // mask for this row in vertical-byte layout
|
||||
const uint32_t rowBase = y * rowBytes;
|
||||
|
||||
// process full 8-pixel bytes
|
||||
for (uint32_t xb = 0; xb < rowBytes; ++xb) {
|
||||
uint32_t x0 = xb * 8;
|
||||
// read up to 8 source bytes (vertical-byte per column)
|
||||
uint8_t b0 = (x0 + 0 < w) ? buffer[base + x0 + 0] : 0;
|
||||
uint8_t b1 = (x0 + 1 < w) ? buffer[base + x0 + 1] : 0;
|
||||
uint8_t b2 = (x0 + 2 < w) ? buffer[base + x0 + 2] : 0;
|
||||
uint8_t b3 = (x0 + 3 < w) ? buffer[base + x0 + 3] : 0;
|
||||
uint8_t b4 = (x0 + 4 < w) ? buffer[base + x0 + 4] : 0;
|
||||
uint8_t b5 = (x0 + 5 < w) ? buffer[base + x0 + 5] : 0;
|
||||
uint8_t b6 = (x0 + 6 < w) ? buffer[base + x0 + 6] : 0;
|
||||
uint8_t b7 = (x0 + 7 < w) ? buffer[base + x0 + 7] : 0;
|
||||
|
||||
// build output byte: MSB = leftmost pixel
|
||||
uint8_t out = 0;
|
||||
out |= (uint8_t)((b0 & bitMask) ? 0x80 : 0x00);
|
||||
out |= (uint8_t)((b1 & bitMask) ? 0x40 : 0x00);
|
||||
out |= (uint8_t)((b2 & bitMask) ? 0x20 : 0x00);
|
||||
out |= (uint8_t)((b3 & bitMask) ? 0x10 : 0x00);
|
||||
out |= (uint8_t)((b4 & bitMask) ? 0x08 : 0x00);
|
||||
out |= (uint8_t)((b5 & bitMask) ? 0x04 : 0x00);
|
||||
out |= (uint8_t)((b6 & bitMask) ? 0x02 : 0x00);
|
||||
out |= (uint8_t)((b7 & bitMask) ? 0x01 : 0x00);
|
||||
|
||||
// handle partial byte at end of row by masking off invalid bits
|
||||
uint8_t mask = 0xFF;
|
||||
uint32_t bitsRemain = (w > x0) ? (w - x0) : 0;
|
||||
if (bitsRemain > 0 && bitsRemain < 8) {
|
||||
mask = (uint8_t)(0xFF << (8 - bitsRemain));
|
||||
out &= mask;
|
||||
}
|
||||
|
||||
// invert to FASTEPD polarity
|
||||
out = (~out) & mask;
|
||||
|
||||
uint32_t pos = rowBase + xb;
|
||||
uint8_t prevVal = prev ? (prev[pos] & mask) : 0x00;
|
||||
// Consider this byte changed if previous buffer differs (or prev is null)
|
||||
bool changed = (prev == nullptr) || (prevVal != out);
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
if (changed && prev)
|
||||
markDirtyBits(prev, pos, mask, out);
|
||||
#endif
|
||||
|
||||
// mark row changed only if the previous buffer differs
|
||||
if (changed) {
|
||||
if (y < (uint32_t)newTop)
|
||||
newTop = y;
|
||||
if ((int)y > newBottom)
|
||||
newBottom = y;
|
||||
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
|
||||
// record changed column bytes
|
||||
if ((int)xb < newLeftByte)
|
||||
newLeftByte = (int)xb;
|
||||
if ((int)xb > newRightByte)
|
||||
newRightByte = (int)xb;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Always write the computed value into the current buffer (avoid leaving stale bytes)
|
||||
cur[pos] = (cur[pos] & ~mask) | out;
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing changed, avoid any panel update
|
||||
if (newBottom < 0) {
|
||||
LOG_DEBUG("no pixel changes detected, skipping update (conv)");
|
||||
previousImageHash = imageHash; // still remember that frame
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose partial vs full update using heuristic
|
||||
// Decide if we should force a full update after many fast updates
|
||||
bool forceFull = (fastRefreshCount >= EPD_FULLSLOW_PERIOD);
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// If ghost pixels exceed limit, force a full update to clear ghosting
|
||||
if (ghostPixelCount > ghostPixelLimit) {
|
||||
LOG_WARN("ghost pixels %u > limit %u, forcing full refresh", ghostPixelCount, ghostPixelLimit);
|
||||
forceFull = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Compute pixel bounds from newTop/newBottom
|
||||
int startRow = (newTop / 8) * 8;
|
||||
int endRow = (newBottom / 8) * 8 + 7;
|
||||
|
||||
LOG_DEBUG("EPD update rows=%d..%d alignedRows=%d..%d rowBytes=%u", newTop, newBottom, startRow, endRow, rowBytes);
|
||||
|
||||
if (epaper->getMode() == BB_MODE_1BPP && !forceFull && (newBottom - newTop) <= EPD_PARTIAL_THRESHOLD_ROWS) {
|
||||
// Prefer partial update path if driver is reliable; otherwise use clipped fullUpdate fallback.
|
||||
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
|
||||
// Workaround for FastEPD partial update bug: use clipped fullUpdate instead
|
||||
// Build a pixel rectangle for a clipped fullUpdate using the changed columns
|
||||
int startCol = (newLeftByte <= newRightByte) ? (newLeftByte * 8) : 0;
|
||||
int endCol = (newLeftByte <= newRightByte) ? ((newRightByte + 1) * 8 - 1) : (w - 1);
|
||||
|
||||
BB_RECT rect{startCol, startRow, endCol - startCol + 1, endRow - startRow + 1};
|
||||
// LOG_DEBUG("Using clipped fullUpdate rect x=%d y=%d w=%d h=%d", rect.x, rect.y, rect.w, rect.h);
|
||||
epaper->fullUpdate(CLEAR_FAST, false, &rect);
|
||||
#else
|
||||
// Use rows for partial update
|
||||
LOG_DEBUG("calling partialUpdate startRow=%d endRow=%d", startRow, endRow);
|
||||
epaper->partialUpdate(true, startRow, endRow);
|
||||
#endif
|
||||
epaper->backupPlane();
|
||||
fastRefreshCount++;
|
||||
} else {
|
||||
// Full update: run async if possible (startAsyncFullUpdate will fall back to blocking)
|
||||
startAsyncFullUpdate(forceFull ? CLEAR_SLOW : CLEAR_FAST);
|
||||
}
|
||||
|
||||
lastUpdateMs = millis();
|
||||
previousImageHash = imageHash;
|
||||
|
||||
// Keep same behavior as before
|
||||
lastDrawMsec = millis();
|
||||
}
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// markDirtyBits: mark per-bit dirty flags and update ghostPixelCount
|
||||
void EInkParallelDisplay::markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out)
|
||||
{
|
||||
// defensive: need dirtyPixels allocated and prevBuf valid
|
||||
if (!dirtyPixels || !prevBuf)
|
||||
return;
|
||||
|
||||
// 'out' is in FASTEPD polarity (1 = black, 0 = white)
|
||||
uint8_t newBlack = out & mask; // bits that will be black now
|
||||
uint8_t newWhite = (~out) & mask; // bits that will be white now
|
||||
|
||||
// previously recorded dirty bits for this byte
|
||||
uint8_t before = dirtyPixels[pos];
|
||||
|
||||
// Ghost bits: bits that were previously marked dirty and are now being driven white
|
||||
uint8_t ghostBits = before & newWhite;
|
||||
if (ghostBits) {
|
||||
ghostPixelCount += __builtin_popcount((unsigned)ghostBits);
|
||||
}
|
||||
|
||||
// Only mark bits dirty when they turn black now (accumulate until a full refresh)
|
||||
uint8_t newlyDirty = newBlack & (~before);
|
||||
if (newlyDirty) {
|
||||
dirtyPixels[pos] |= newlyDirty;
|
||||
}
|
||||
}
|
||||
|
||||
// reset ghost tracking (call after a full refresh)
|
||||
void EInkParallelDisplay::resetGhostPixelTracking()
|
||||
{
|
||||
if (!dirtyPixels)
|
||||
return;
|
||||
memset(dirtyPixels, 0, dirtyPixelsSize);
|
||||
ghostPixelCount = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
/*
|
||||
* forceDisplay: use lastDrawMsec
|
||||
*/
|
||||
bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
|
||||
{
|
||||
uint32_t now = millis();
|
||||
if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) {
|
||||
display();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void EInkParallelDisplay::endUpdate()
|
||||
{
|
||||
{
|
||||
// ensure any async full update is started/completed
|
||||
if (asyncFullRunning.load()) {
|
||||
// nothing to do; background task will run and call backupPlane when done
|
||||
} else {
|
||||
epaper->fullUpdate(CLEAR_FAST, false);
|
||||
epaper->backupPlane();
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
resetGhostPixelTracking();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#ifdef USE_EINK_PARALLELDISPLAY
|
||||
#include <OLEDDisplay.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
class FASTEPD;
|
||||
|
||||
/**
|
||||
* Adapter for E-Ink 8-bit parallel displays (EPD), specifically devices supported by FastEPD library
|
||||
*/
|
||||
class EInkParallelDisplay : public OLEDDisplay
|
||||
{
|
||||
public:
|
||||
enum EpdRotation {
|
||||
EPD_ROT_LANDSCAPE = 0,
|
||||
EPD_ROT_PORTRAIT = 90,
|
||||
EPD_ROT_INVERTED_LANDSCAPE = 180,
|
||||
EPD_ROT_INVERTED_PORTRAIT = 270,
|
||||
};
|
||||
|
||||
EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rotation);
|
||||
virtual ~EInkParallelDisplay();
|
||||
|
||||
// OLEDDisplay virtuals
|
||||
bool connect() override;
|
||||
void sendCommand(uint8_t com) override;
|
||||
int getBufferOffset(void) override { return 0; }
|
||||
|
||||
void display(void) override;
|
||||
bool forceDisplay(uint32_t msecLimit = 1000);
|
||||
void endUpdate();
|
||||
|
||||
protected:
|
||||
uint32_t lastDrawMsec = 0;
|
||||
FASTEPD *epaper;
|
||||
|
||||
private:
|
||||
// Async full-refresh support
|
||||
std::atomic<bool> asyncFullRunning{false};
|
||||
TaskHandle_t asyncTaskHandle = nullptr;
|
||||
void startAsyncFullUpdate(int clearMode);
|
||||
static void asyncFullUpdateTask(void *pvParameters);
|
||||
|
||||
#ifdef EINK_LIMIT_GHOSTING_PX
|
||||
// helpers
|
||||
void resetGhostPixelTracking();
|
||||
void markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out);
|
||||
void countGhostPixelsAndMaybePromote(int &newTop, int &newBottom, bool &forceFull);
|
||||
|
||||
// per-bit dirty buffer (same format as epaper buffers): one bit == one pixel
|
||||
uint8_t *dirtyPixels = nullptr;
|
||||
size_t dirtyPixelsSize = 0;
|
||||
uint32_t ghostPixelCount = 0;
|
||||
uint32_t ghostPixelLimit = EINK_LIMIT_GHOSTING_PX;
|
||||
#endif
|
||||
|
||||
EpdRotation rotation;
|
||||
uint32_t previousImageHash = 0;
|
||||
uint32_t lastUpdateMs = 0;
|
||||
int fastRefreshCount = 0;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,135 @@
|
||||
// Wrapper class for GxEPD2_BW
|
||||
|
||||
// Generic signature at build-time, so that we can detect display model at run-time
|
||||
// Workaround for issue of GxEPD2_BW objects not having a shared base class
|
||||
// Only exposes methods which we are actually using
|
||||
|
||||
template <typename Driver0, typename Driver1> class GxEPD2_Multi
|
||||
{
|
||||
public:
|
||||
void drawPixel(int16_t x, int16_t y, uint16_t color)
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->drawPixel(x, y, color);
|
||||
else
|
||||
driver1->drawPixel(x, y, color);
|
||||
}
|
||||
|
||||
bool nextPage()
|
||||
{
|
||||
if (which == 0)
|
||||
return driver0->nextPage();
|
||||
else
|
||||
return driver1->nextPage();
|
||||
}
|
||||
|
||||
void hibernate()
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->hibernate();
|
||||
else
|
||||
driver1->hibernate();
|
||||
}
|
||||
|
||||
void init(uint32_t serial_diag_bitrate = 0)
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->init(serial_diag_bitrate);
|
||||
else
|
||||
driver1->init(serial_diag_bitrate);
|
||||
}
|
||||
|
||||
void init(uint32_t serial_diag_bitrate, bool initial, uint16_t reset_duration = 20, bool pulldown_rst_mode = false)
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode);
|
||||
else
|
||||
driver1->init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode);
|
||||
}
|
||||
|
||||
void setRotation(uint8_t x)
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->setRotation(x);
|
||||
else
|
||||
driver1->setRotation(x);
|
||||
}
|
||||
|
||||
void setPartialWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h)
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->setPartialWindow(x, y, w, h);
|
||||
else
|
||||
driver1->setPartialWindow(x, y, w, h);
|
||||
}
|
||||
|
||||
void setFullWindow()
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->setFullWindow();
|
||||
else
|
||||
driver1->setFullWindow();
|
||||
}
|
||||
|
||||
int16_t width()
|
||||
{
|
||||
if (which == 0)
|
||||
return driver0->width();
|
||||
else
|
||||
return driver1->width();
|
||||
}
|
||||
|
||||
int16_t height()
|
||||
{
|
||||
if (which == 0)
|
||||
return driver0->height();
|
||||
else
|
||||
return driver1->height();
|
||||
}
|
||||
|
||||
void clearScreen(uint8_t value = 0xFF)
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->clearScreen();
|
||||
else
|
||||
driver1->clearScreen();
|
||||
}
|
||||
|
||||
void endAsyncFull()
|
||||
{
|
||||
if (which == 0)
|
||||
driver0->endAsyncFull();
|
||||
else
|
||||
driver1->endAsyncFull();
|
||||
}
|
||||
|
||||
// Exposes methods of the GxEPD2_EPD object which is usually available as GxEPD2_BW::epd
|
||||
class Epd2Wrapper
|
||||
{
|
||||
public:
|
||||
bool isBusy() { return m_epd2->isBusy(); }
|
||||
GxEPD2_EPD *m_epd2;
|
||||
} epd2;
|
||||
|
||||
// Constructor
|
||||
// Select driver by passing whichDriver as 0 or 1
|
||||
GxEPD2_Multi(uint8_t whichDriver, int16_t cs, int16_t dc, int16_t rst, int16_t busy, SPIClass &spi)
|
||||
{
|
||||
assert(whichDriver == 0 || whichDriver == 1);
|
||||
which = whichDriver;
|
||||
LOG_DEBUG("GxEPD2_Multi driver: %d", which);
|
||||
|
||||
if (which == 0) {
|
||||
driver0 = new GxEPD2_BW<Driver0, Driver0::HEIGHT>(Driver0(cs, dc, rst, busy, spi));
|
||||
epd2.m_epd2 = &(driver0->epd2);
|
||||
} else if (which == 1) {
|
||||
driver1 = new GxEPD2_BW<Driver1, Driver1::HEIGHT>(Driver1(cs, dc, rst, busy, spi));
|
||||
epd2.m_epd2 = &(driver1->epd2);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
uint8_t which;
|
||||
GxEPD2_BW<Driver0, Driver0::HEIGHT> *driver0;
|
||||
GxEPD2_BW<Driver1, Driver1::HEIGHT> *driver1;
|
||||
};
|
||||
+126
-117
@@ -27,14 +27,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "configuration.h"
|
||||
#include "meshUtils.h"
|
||||
#if HAS_SCREEN
|
||||
#include "EInkParallelDisplay.h"
|
||||
#include <OLEDDisplay.h>
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
#include "graphics/BaseUIEInkDisplay.h"
|
||||
// Provided by each niche-enabled variant's nicheGraphics.h (defined once, in main.cpp TU).
|
||||
extern NicheGraphics::BaseUIEInkDisplay *setupNicheGraphicsBaseUI();
|
||||
#endif
|
||||
|
||||
#include "DisplayFormatters.h"
|
||||
#include "TimeFormatters.h"
|
||||
#include "draw/ClockRenderer.h"
|
||||
@@ -65,7 +60,6 @@ extern NicheGraphics::BaseUIEInkDisplay *setupNicheGraphicsBaseUI();
|
||||
#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"
|
||||
@@ -104,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
|
||||
@@ -142,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
|
||||
// ==============================
|
||||
@@ -333,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;
|
||||
|
||||
@@ -359,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;
|
||||
}
|
||||
@@ -449,9 +365,14 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR)
|
||||
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
|
||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||
#elif defined(USE_EINK) && defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
// NicheGraphics-backed BaseUI E-Ink path. Variant provides setupNicheGraphicsBaseUI() in its nicheGraphics.h.
|
||||
dispdev = setupNicheGraphicsBaseUI();
|
||||
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) && !defined(USE_EINK_PARALLELDISPLAY)
|
||||
dispdev = new EInkDisplay(address.address, -1, -1, geometry,
|
||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||
#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
|
||||
dispdev = new EInkDynamicDisplay(address.address, -1, -1, geometry,
|
||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||
#elif defined(USE_EINK_PARALLELDISPLAY)
|
||||
dispdev = new EInkParallelDisplay(EPD_WIDTH, EPD_HEIGHT, EInkParallelDisplay::EPD_ROT_PORTRAIT);
|
||||
#elif defined(USE_ST7567)
|
||||
dispdev = new ST7567Wire(address.address, -1, -1, geometry,
|
||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||
@@ -841,8 +762,10 @@ void Screen::forceDisplay(bool forceUiUpdate)
|
||||
}
|
||||
|
||||
// Tell EInk class to update the display
|
||||
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
static_cast<NicheGraphics::BaseUIEInkDisplay *>(dispdev)->forceDisplay();
|
||||
#if defined(USE_EINK_PARALLELDISPLAY)
|
||||
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay();
|
||||
#elif defined(USE_EINK)
|
||||
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
|
||||
#endif
|
||||
#else
|
||||
// No delay between UI frame rendering
|
||||
@@ -1000,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();
|
||||
@@ -1029,7 +939,12 @@ int32_t Screen::runOnce()
|
||||
NotificationRenderer::current_notification_type != notificationTypeEnum::text_input &&
|
||||
!Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) {
|
||||
|
||||
// Carousel rotations let BaseUIEInkDisplay's DisplayHealth debt model decide FAST vs FULL.
|
||||
// If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead
|
||||
// Carousel is potentially a major source of E-Ink display wear
|
||||
#if !defined(EINK_BACKGROUND_USES_FAST)
|
||||
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC);
|
||||
#endif
|
||||
|
||||
LOG_DEBUG("LastScreenTransition exceeded %ums transition to next frame", (millis() - lastScreenTransition));
|
||||
handleOnPress();
|
||||
}
|
||||
@@ -1063,8 +978,11 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
|
||||
static FrameCallback screensaverFrame;
|
||||
static OverlayCallback screensaverOverlay;
|
||||
|
||||
// Join (await) any currently running async refresh before drawing the screensaver frame.
|
||||
#if defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY)
|
||||
// Join (await) a currently running async refresh, then run the post-update code.
|
||||
// Avoid skipping of screensaver frame. Would otherwise be handled by NotifiedWorkerThread.
|
||||
EINK_JOIN_ASYNCREFRESH(dispdev);
|
||||
#endif
|
||||
|
||||
// If: one-off screensaver frame passed as argument. Handles doDeepSleep()
|
||||
if (einkScreensaver != NULL) {
|
||||
@@ -1087,17 +1005,23 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
|
||||
ui->update();
|
||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||
|
||||
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
static_cast<NicheGraphics::BaseUIEInkDisplay *>(dispdev)->forceDisplay(0);
|
||||
#if defined(USE_EINK_PARALLELDISPLAY)
|
||||
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay(0);
|
||||
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY)
|
||||
// Old EInkDisplay class
|
||||
static_cast<EInkDisplay *>(dispdev)->forceDisplay(0); // Screen::forceDisplay(), but override rate-limit
|
||||
#endif
|
||||
|
||||
// Prepare now for next frame, shown when display wakes
|
||||
ui->setOverlays(NULL, 0); // Clear overlay
|
||||
setFrames(FOCUS_PRESERVE); // Return to normal display updates, showing same frame as before screensaver, ideally
|
||||
|
||||
// Pick a refresh method for when the display wakes. RESPONSIVE = FAST; DisplayHealth
|
||||
// will promote to FULL on its own schedule if FAST debt has built up.
|
||||
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE);
|
||||
// Pick a refresh method, for when display wakes
|
||||
#ifdef EINK_HASQUIRK_GHOSTING
|
||||
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused"
|
||||
#else
|
||||
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1273,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,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) {
|
||||
@@ -1341,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
|
||||
}
|
||||
|
||||
@@ -1495,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 {
|
||||
@@ -1831,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) {
|
||||
|
||||
+19
-4
@@ -87,7 +87,8 @@ class Screen
|
||||
#include <AutoOLEDWire.h>
|
||||
#endif
|
||||
|
||||
#include "BaseUIEInkDisplay.h"
|
||||
#include "EInkDisplay2.h"
|
||||
#include "EInkDynamicDisplay.h"
|
||||
#include "PointStruct.h"
|
||||
#include "TFTDisplay.h"
|
||||
#include "TypedQueue.h"
|
||||
@@ -329,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; }
|
||||
@@ -668,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
|
||||
@@ -777,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);
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
#include "./EInkParallel.h"
|
||||
|
||||
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
|
||||
|
||||
#include "FastEPD.h"
|
||||
|
||||
using namespace NicheGraphics::Drivers;
|
||||
|
||||
EInkParallel::EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock)
|
||||
: EInk(width, height, (UpdateTypes)(FULL | FAST)), panelType(panelType), panelClock(panelClock)
|
||||
{
|
||||
}
|
||||
|
||||
EInkParallel::~EInkParallel()
|
||||
{
|
||||
if (asyncRunning.load()) {
|
||||
for (int i = 0; i < 50 && asyncRunning.load(); ++i)
|
||||
delay(50);
|
||||
if (asyncTaskHandle) {
|
||||
vTaskDelete(asyncTaskHandle);
|
||||
asyncTaskHandle = nullptr;
|
||||
}
|
||||
}
|
||||
delete epaper;
|
||||
}
|
||||
|
||||
void EInkParallel::begin(SPIClass *, uint8_t, uint8_t, uint8_t, uint8_t)
|
||||
{
|
||||
// Parallel panels don't use the SPI args; FastEPD owns the bus.
|
||||
if (!epaper) {
|
||||
epaper = new FASTEPD;
|
||||
epaper->initPanel((int)panelType, panelClock);
|
||||
postPanelInit();
|
||||
epaper->setMode(BB_MODE_1BPP);
|
||||
epaper->clearWhite();
|
||||
epaper->fullUpdate(true);
|
||||
}
|
||||
}
|
||||
|
||||
void EInkParallel::update(uint8_t *imageData, UpdateTypes type)
|
||||
{
|
||||
if (!epaper)
|
||||
return;
|
||||
|
||||
pendingType = type;
|
||||
copyImageInverted(imageData);
|
||||
|
||||
if (type == FULL) {
|
||||
// Pick CLEAR_SLOW periodically to clear ghosting.
|
||||
const int clearMode = (fastRefreshCount >= FULL_SLOW_PERIOD) ? CLEAR_SLOW : CLEAR_FAST;
|
||||
fastRefreshCount = 0;
|
||||
|
||||
if (!asyncRunning.load()) {
|
||||
asyncRunning.store(true);
|
||||
BaseType_t rc =
|
||||
xTaskCreatePinnedToCore(asyncFullTask, "epd_full", 4096 / sizeof(StackType_t), this, 2, &asyncTaskHandle,
|
||||
#if CONFIG_FREERTOS_UNICORE
|
||||
0
|
||||
#else
|
||||
1
|
||||
#endif
|
||||
);
|
||||
if (rc != pdPASS) {
|
||||
LOG_WARN("Async full failed; running blocking");
|
||||
epaper->fullUpdate(clearMode, false);
|
||||
epaper->backupPlane();
|
||||
asyncRunning.store(false);
|
||||
asyncTaskHandle = nullptr;
|
||||
return; // synchronous: nothing to poll
|
||||
}
|
||||
// Begin polling for completion.
|
||||
beginPolling(100, 1500);
|
||||
}
|
||||
} else {
|
||||
// FAST: synchronous partial / clipped fullUpdate. Block briefly here.
|
||||
epaper->fullUpdate(CLEAR_FAST, false);
|
||||
epaper->backupPlane();
|
||||
fastRefreshCount++;
|
||||
// No polling needed; isUpdateDone() will report done immediately.
|
||||
beginPolling(10, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void EInkParallel::asyncFullTask(void *param)
|
||||
{
|
||||
auto *self = static_cast<EInkParallel *>(param);
|
||||
if (!self) {
|
||||
vTaskDelete(nullptr);
|
||||
return;
|
||||
}
|
||||
self->epaper->fullUpdate(CLEAR_FAST, false);
|
||||
self->epaper->backupPlane();
|
||||
self->asyncRunning.store(false);
|
||||
self->asyncTaskHandle = nullptr;
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
bool EInkParallel::isUpdateDone()
|
||||
{
|
||||
return !asyncRunning.load();
|
||||
}
|
||||
|
||||
void EInkParallel::finalizeUpdate()
|
||||
{
|
||||
pendingType = UpdateTypes::UNSPECIFIED;
|
||||
}
|
||||
|
||||
// Convert a niche-format buffer (row-major, MSB-left, 1=WHITE) into FastEPD's currentBuffer
|
||||
// (row-major, MSB-left, 1=BLACK). Polarity inversion only.
|
||||
void EInkParallel::copyImageInverted(const uint8_t *src)
|
||||
{
|
||||
uint8_t *dst = epaper->currentBuffer();
|
||||
if (!dst || !src)
|
||||
return;
|
||||
|
||||
const uint16_t rowBytes = ((width - 1) / 8) + 1;
|
||||
const uint32_t total = rowBytes * height;
|
||||
|
||||
// Mask off bits beyond the panel width in the trailing byte of each row.
|
||||
const uint8_t trailingMask = (uint8_t)(0xFFu << ((rowBytes * 8) - width));
|
||||
|
||||
for (uint16_t y = 0; y < height; y++) {
|
||||
const uint32_t base = y * rowBytes;
|
||||
for (uint16_t b = 0; b < rowBytes - 1; b++) {
|
||||
dst[base + b] = ~src[base + b];
|
||||
}
|
||||
dst[base + rowBytes - 1] = (~src[base + rowBytes - 1]) & trailingMask;
|
||||
}
|
||||
(void)total;
|
||||
}
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
|
||||
Parallel-EPD niche driver, backed by FastEPD.
|
||||
|
||||
Used for boards with an 8-bit parallel EPD interface (e.g. LILYGO T5 S3 ePaper).
|
||||
The base class signature passes SPI parameters; this driver ignores them and uses FastEPD
|
||||
to drive the parallel bus directly.
|
||||
|
||||
Gated on NICHE_HAS_FASTEPD because FastEPD is a heavy dependency that only parallel-EPD
|
||||
variants want pulled in. Variants opt in by defining NICHE_HAS_FASTEPD in their platformio.ini
|
||||
and adding the FastEPD library to lib_deps.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
|
||||
|
||||
#include "./EInk.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
class FASTEPD;
|
||||
|
||||
namespace NicheGraphics::Drivers
|
||||
{
|
||||
|
||||
class EInkParallel : public EInk
|
||||
{
|
||||
public:
|
||||
EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock = 28000000);
|
||||
~EInkParallel();
|
||||
|
||||
// SPI parameters are unused for parallel panels.
|
||||
void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override;
|
||||
void update(uint8_t *imageData, UpdateTypes type) override;
|
||||
|
||||
FASTEPD *fastEpd() { return epaper; }
|
||||
|
||||
protected:
|
||||
bool isUpdateDone() override;
|
||||
void finalizeUpdate() override;
|
||||
|
||||
// Hook for boards that need to bring up GPIO expanders / power pins after FastEPD::initPanel.
|
||||
virtual void postPanelInit() {}
|
||||
|
||||
private:
|
||||
void copyImageInverted(const uint8_t *src);
|
||||
static void asyncFullTask(void *param);
|
||||
|
||||
FASTEPD *epaper = nullptr;
|
||||
uint32_t panelType;
|
||||
uint32_t panelClock;
|
||||
|
||||
UpdateTypes pendingType = UpdateTypes::UNSPECIFIED;
|
||||
std::atomic<bool> asyncRunning{false};
|
||||
TaskHandle_t asyncTaskHandle = nullptr;
|
||||
|
||||
uint8_t fastRefreshCount = 0;
|
||||
static constexpr uint8_t FULL_SLOW_PERIOD = 100;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::Drivers
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
|
||||
@@ -1,49 +0,0 @@
|
||||
#include "./GDEH0122T61.h"
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
using namespace NicheGraphics::Drivers;
|
||||
|
||||
void GDEH0122T61::configScanning()
|
||||
{
|
||||
sendCommand(0x01);
|
||||
sendData(0xAF); // Scan until gate 175 (176px vertical resolution, low byte)
|
||||
sendData(0x00); // high byte
|
||||
sendData(0x00);
|
||||
}
|
||||
|
||||
void GDEH0122T61::configWaveform()
|
||||
{
|
||||
sendCommand(0x3C);
|
||||
sendData(0x05);
|
||||
|
||||
sendCommand(0x18);
|
||||
sendData(0x80);
|
||||
}
|
||||
|
||||
void GDEH0122T61::configUpdateSequence()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
sendCommand(0x22);
|
||||
sendData(0xFF);
|
||||
break;
|
||||
case FULL:
|
||||
default:
|
||||
sendCommand(0x22);
|
||||
sendData(0xF7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void GDEH0122T61::detachFromUpdate()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
return beginPolling(50, 250);
|
||||
case FULL:
|
||||
default:
|
||||
return beginPolling(100, 1500);
|
||||
}
|
||||
}
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
|
||||
E-Ink display driver
|
||||
- GDEH0122T61
|
||||
- Manufacturer: Good Display
|
||||
- Size: 1.22 inch
|
||||
- Resolution: 192px x 176px
|
||||
- Controller IC: SSD1681 (operating in a sub-200x200 window)
|
||||
|
||||
Used by: t-echo-lite.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "./SSD16XX.h"
|
||||
|
||||
namespace NicheGraphics::Drivers
|
||||
{
|
||||
class GDEH0122T61 : public SSD16XX
|
||||
{
|
||||
private:
|
||||
static constexpr uint32_t width = 192;
|
||||
static constexpr uint32_t height = 176;
|
||||
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
|
||||
|
||||
public:
|
||||
GDEH0122T61() : SSD16XX(width, height, supported) {}
|
||||
|
||||
protected:
|
||||
void configScanning() override;
|
||||
void configWaveform() override;
|
||||
void configUpdateSequence() override;
|
||||
void detachFromUpdate() override;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::Drivers
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,57 +0,0 @@
|
||||
#include "./GDEQ031T10.h"
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
using namespace NicheGraphics::Drivers;
|
||||
|
||||
void GDEQ031T10::configScanning()
|
||||
{
|
||||
sendCommand(0x01);
|
||||
sendData(0x3F); // 319, low byte
|
||||
sendData(0x01); // 319, high byte
|
||||
sendData(0x00);
|
||||
}
|
||||
|
||||
void GDEQ031T10::configWaveform()
|
||||
{
|
||||
sendCommand(0x3C);
|
||||
sendData(0x01);
|
||||
|
||||
sendCommand(0x18);
|
||||
sendData(0x80);
|
||||
}
|
||||
|
||||
void GDEQ031T10::configUpdateSequence()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
sendCommand(0x21);
|
||||
sendData(0x00);
|
||||
sendData(0x00);
|
||||
|
||||
sendCommand(0x22);
|
||||
sendData(0xFF);
|
||||
break;
|
||||
case FULL:
|
||||
default:
|
||||
sendCommand(0x21);
|
||||
sendData(0x40);
|
||||
sendData(0x00);
|
||||
|
||||
sendCommand(0x22);
|
||||
sendData(0xF7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void GDEQ031T10::detachFromUpdate()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
return beginPolling(50, 400);
|
||||
case FULL:
|
||||
default:
|
||||
return beginPolling(100, 2500);
|
||||
}
|
||||
}
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
|
||||
E-Ink display driver
|
||||
- GDEQ031T10
|
||||
- Manufacturer: Good Display
|
||||
- Size: 3.1 inch
|
||||
- Resolution: 240px x 320px
|
||||
- Controller IC: SSD1677 (SSD16XX-family, larger memory range)
|
||||
|
||||
Used by: t-deck-pro.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "./SSD16XX.h"
|
||||
|
||||
namespace NicheGraphics::Drivers
|
||||
{
|
||||
class GDEQ031T10 : public SSD16XX
|
||||
{
|
||||
private:
|
||||
static constexpr uint32_t width = 240;
|
||||
static constexpr uint32_t height = 320;
|
||||
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
|
||||
|
||||
public:
|
||||
GDEQ031T10() : SSD16XX(width, height, supported) {}
|
||||
|
||||
protected:
|
||||
void configScanning() override;
|
||||
void configWaveform() override;
|
||||
void configUpdateSequence() override;
|
||||
void detachFromUpdate() override;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::Drivers
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,49 +0,0 @@
|
||||
#include "./GDEY029T94.h"
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
using namespace NicheGraphics::Drivers;
|
||||
|
||||
void GDEY029T94::configScanning()
|
||||
{
|
||||
sendCommand(0x01);
|
||||
sendData(0x27); // 295, low byte
|
||||
sendData(0x01); // 295, high byte
|
||||
sendData(0x00);
|
||||
}
|
||||
|
||||
void GDEY029T94::configWaveform()
|
||||
{
|
||||
sendCommand(0x3C);
|
||||
sendData(0x05);
|
||||
|
||||
sendCommand(0x18);
|
||||
sendData(0x80);
|
||||
}
|
||||
|
||||
void GDEY029T94::configUpdateSequence()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
sendCommand(0x22);
|
||||
sendData(0xFF);
|
||||
break;
|
||||
case FULL:
|
||||
default:
|
||||
sendCommand(0x22);
|
||||
sendData(0xF7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void GDEY029T94::detachFromUpdate()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
return beginPolling(50, 300);
|
||||
case FULL:
|
||||
default:
|
||||
return beginPolling(100, 2000);
|
||||
}
|
||||
}
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
|
||||
E-Ink display driver
|
||||
- GDEY029T94 (also sold as GDEY029T94-V2)
|
||||
- Manufacturer: Good Display
|
||||
- Size: 2.9 inch
|
||||
- Resolution: 128px x 296px
|
||||
- Controller IC: SSD1680
|
||||
|
||||
Used by: esp32-s3-pico, crowpanel-esp32s3-2-epaper.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "./SSD16XX.h"
|
||||
|
||||
namespace NicheGraphics::Drivers
|
||||
{
|
||||
class GDEY029T94 : public SSD16XX
|
||||
{
|
||||
private:
|
||||
static constexpr uint32_t width = 128;
|
||||
static constexpr uint32_t height = 296;
|
||||
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
|
||||
|
||||
public:
|
||||
GDEY029T94() : SSD16XX(width, height, supported) {}
|
||||
|
||||
protected:
|
||||
void configScanning() override;
|
||||
void configWaveform() override;
|
||||
void configUpdateSequence() override;
|
||||
void detachFromUpdate() override;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::Drivers
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,49 +0,0 @@
|
||||
#include "./GDEY042T81.h"
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
using namespace NicheGraphics::Drivers;
|
||||
|
||||
void GDEY042T81::configWaveform()
|
||||
{
|
||||
sendCommand(0x3C);
|
||||
sendData(0x01);
|
||||
|
||||
sendCommand(0x18);
|
||||
sendData(0x80);
|
||||
}
|
||||
|
||||
void GDEY042T81::configUpdateSequence()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
sendCommand(0x21);
|
||||
sendData(0x00);
|
||||
sendData(0x00);
|
||||
|
||||
sendCommand(0x22);
|
||||
sendData(0xFF);
|
||||
break;
|
||||
case FULL:
|
||||
default:
|
||||
sendCommand(0x21);
|
||||
sendData(0x40);
|
||||
sendData(0x00);
|
||||
|
||||
sendCommand(0x22);
|
||||
sendData(0xF7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void GDEY042T81::detachFromUpdate()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
return beginPolling(50, 1000);
|
||||
case FULL:
|
||||
default:
|
||||
return beginPolling(100, 3500);
|
||||
}
|
||||
}
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
|
||||
E-Ink display driver
|
||||
- GDEY042T81
|
||||
- Manufacturer: Good Display
|
||||
- Size: 4.2 inch
|
||||
- Resolution: 400px x 300px
|
||||
- Controller IC: SSD1683
|
||||
|
||||
Used by: ME25LS01-4Y10TD_e-ink.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "./SSD16XX.h"
|
||||
|
||||
namespace NicheGraphics::Drivers
|
||||
{
|
||||
class GDEY042T81 : public SSD16XX
|
||||
{
|
||||
private:
|
||||
static constexpr uint32_t width = 400;
|
||||
static constexpr uint32_t height = 300;
|
||||
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
|
||||
|
||||
public:
|
||||
GDEY042T81() : SSD16XX(width, height, supported) {}
|
||||
|
||||
protected:
|
||||
void configWaveform() override;
|
||||
void configUpdateSequence() override;
|
||||
void detachFromUpdate() override;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::Drivers
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,57 +0,0 @@
|
||||
#include "./GDEY0579T93.h"
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
using namespace NicheGraphics::Drivers;
|
||||
|
||||
void GDEY0579T93::configScanning()
|
||||
{
|
||||
sendCommand(0x01);
|
||||
sendData(0x0F); // 271, low byte
|
||||
sendData(0x01); // 271, high byte
|
||||
sendData(0x00);
|
||||
}
|
||||
|
||||
void GDEY0579T93::configWaveform()
|
||||
{
|
||||
sendCommand(0x3C);
|
||||
sendData(0x01);
|
||||
|
||||
sendCommand(0x18);
|
||||
sendData(0x80);
|
||||
}
|
||||
|
||||
void GDEY0579T93::configUpdateSequence()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
sendCommand(0x21);
|
||||
sendData(0x00);
|
||||
sendData(0x00);
|
||||
|
||||
sendCommand(0x22);
|
||||
sendData(0xFF);
|
||||
break;
|
||||
case FULL:
|
||||
default:
|
||||
sendCommand(0x21);
|
||||
sendData(0x40);
|
||||
sendData(0x00);
|
||||
|
||||
sendCommand(0x22);
|
||||
sendData(0xF7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void GDEY0579T93::detachFromUpdate()
|
||||
{
|
||||
switch (updateType) {
|
||||
case FAST:
|
||||
return beginPolling(100, 2000);
|
||||
case FULL:
|
||||
default:
|
||||
return beginPolling(150, 5000);
|
||||
}
|
||||
}
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
|
||||
E-Ink display driver
|
||||
- GDEY0579T93
|
||||
- Manufacturer: Good Display
|
||||
- Size: 5.79 inch
|
||||
- Resolution: 792px x 272px
|
||||
- Controller IC: SSD1683 (extended memory range)
|
||||
|
||||
Used by: crowpanel-esp32s3-5-epaper.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "./SSD16XX.h"
|
||||
|
||||
namespace NicheGraphics::Drivers
|
||||
{
|
||||
class GDEY0579T93 : public SSD16XX
|
||||
{
|
||||
private:
|
||||
static constexpr uint32_t width = 792;
|
||||
static constexpr uint32_t height = 272;
|
||||
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
|
||||
|
||||
public:
|
||||
GDEY0579T93() : SSD16XX(width, height, supported) {}
|
||||
|
||||
protected:
|
||||
void configScanning() override;
|
||||
void configWaveform() override;
|
||||
void configUpdateSequence() override;
|
||||
void detachFromUpdate() override;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::Drivers
|
||||
|
||||
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/DEPG0213BNS800.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class DEPG0213BNS800 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::DEPG0213BNS800();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
uint8_t rotation() const override { return 3; }
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/DEPG0290BNS800.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class DEPG0290BNS800 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::DEPG0290BNS800();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
uint8_t rotation() const override { return 1; }
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/E0213A367.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class E0213A367 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::E0213A367();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
uint8_t rotation() const override { return 3; }
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/GDEH0122T61.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class GDEH0122T61 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::GDEH0122T61();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/GDEQ031T10.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class GDEQ031T10 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::GDEQ031T10();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/GDEW0102T4.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class GDEW0102T4 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::GDEW0102T4();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
uint8_t rotation() const override { return 3; }
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/GDEY0154D67.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class GDEY0154D67 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::GDEY0154D67();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
|
||||
#include "./PanelProfile.h"
|
||||
#include "graphics/eink/Drivers/GDEY0213B74.h"
|
||||
|
||||
namespace NicheGraphics::Panels
|
||||
{
|
||||
class GDEY0213B74 : public PanelProfile
|
||||
{
|
||||
public:
|
||||
NicheGraphics::Drivers::EInk *create() override
|
||||
{
|
||||
prePowerOn();
|
||||
SPIClass *spi = beginSpi();
|
||||
auto *drv = new NicheGraphics::Drivers::GDEY0213B74();
|
||||
drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
|
||||
return drv;
|
||||
}
|
||||
uint8_t rotation() const override { return 3; }
|
||||
};
|
||||
} // namespace NicheGraphics::Panels
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user