mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 03:18:10 +10:00
Compare commits
226
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e2ca8aed4 | ||
|
|
4553d1e0b1 | ||
|
|
784e3748e2 | ||
|
|
b11d29ff31 | ||
|
|
b246bcd72e | ||
|
|
33e2bb70e6 | ||
|
|
220bb4d186 | ||
|
|
6e810741f3 | ||
|
|
cdc47a2aea | ||
|
|
603cce2988 | ||
|
|
fcef46f4b0 | ||
|
|
d559af8477 | ||
|
|
bb86cf4e81 | ||
|
|
6ea0d5ebba | ||
|
|
b2bda3b07a | ||
|
|
41f53177a1 | ||
|
|
2d761f6453 | ||
|
|
7cb071c780 | ||
|
|
b7a9555905 | ||
|
|
0240a00d09 | ||
|
|
c0fcf807c0 | ||
|
|
55f40ecdfd | ||
|
|
90744ee0b7 | ||
|
|
1eb860a3fc | ||
|
|
4ee9598107 | ||
|
|
7066abbb86 | ||
|
|
21cef8c2e5 | ||
|
|
989b8620ba | ||
|
|
173ac58ed7 | ||
|
|
83adfd417a | ||
|
|
a0951f23c3 | ||
|
|
e19f531059 | ||
|
|
24d64a0013 | ||
|
|
3a87fc82c0 | ||
|
|
478444eb02 | ||
|
|
ad23c42fcc | ||
|
|
195f42af82 | ||
|
|
089af764ec | ||
|
|
7be5426f34 | ||
|
|
9ec63b5eb2 | ||
|
|
22a9346fe0 | ||
|
|
c0e52e6e1c | ||
|
|
11df30a85f | ||
|
|
8d8ff21e7c | ||
|
|
9c72767c01 | ||
|
|
6c7ffa1054 | ||
|
|
c0425d7444 | ||
|
|
d7db0f5829 | ||
|
|
f037ce2165 | ||
|
|
bc17d004ab | ||
|
|
048e5187ba | ||
|
|
4234fe6f86 | ||
|
|
126861fd16 | ||
|
|
47a6c4c6a0 | ||
|
|
06a6c3ee20 | ||
|
|
87f1f9d349 | ||
|
|
bfadf0c36a | ||
|
|
24c4162a75 | ||
|
|
b148fac340 | ||
|
|
8dde4eeee1 | ||
|
|
4ccdd80090 | ||
|
|
4d4e14600c | ||
|
|
aec0805a27 | ||
|
|
0bd8dee346 | ||
|
|
7800dc3c8d | ||
|
|
554188e90e | ||
|
|
fb678b9337 | ||
|
|
2828dbe4ca | ||
|
|
a8c8fd7002 | ||
|
|
e7c02da24b | ||
|
|
7347091055 | ||
|
|
55f15076ca | ||
|
|
7421953e8f | ||
|
|
9306e66067 | ||
|
|
439b87b860 | ||
|
|
d47301defc | ||
|
|
04b819a7b5 | ||
|
|
8e653122c7 | ||
|
|
7adfc3f992 | ||
|
|
ba9cadc14d | ||
|
|
924411de59 | ||
|
|
5ea3d143da | ||
|
|
d9195944df | ||
|
|
83a98c81f6 | ||
|
|
837637b70c | ||
|
|
7b3f58875a | ||
|
|
56c897e826 | ||
|
|
b2d980fc25 | ||
|
|
2cc13a1132 | ||
|
|
2ed7bba5e7 | ||
|
|
031f332ec1 | ||
|
|
22d50fe437 | ||
|
|
cde5a08bc5 | ||
|
|
55bf8c25fc | ||
|
|
399dde0f4b | ||
|
|
48747ee43d | ||
|
|
66971a0a26 | ||
|
|
4c24218afb | ||
|
|
7c27f4e2df | ||
|
|
4b4914736f | ||
|
|
92c0133ef9 | ||
|
|
a6b1a69630 | ||
|
|
6171ad8c14 | ||
|
|
28e705de5c | ||
|
|
d8b11f0b14 | ||
|
|
fcb9ec0c2d | ||
|
|
a4b55bc6f2 | ||
|
|
a710841723 | ||
|
|
fb1de111d7 | ||
|
|
b53fe7a1e7 | ||
|
|
2b5daf2438 | ||
|
|
db9fdd6794 | ||
|
|
9361b85f47 | ||
|
|
3b4c66439d | ||
|
|
68383c8bd5 | ||
|
|
76dea77929 | ||
|
|
d7ba178bf1 | ||
|
|
5f836cdf3b | ||
|
|
945f4780ea | ||
|
|
a8a5315460 | ||
|
|
0e38a15d46 | ||
|
|
d8e4389da2 | ||
|
|
25febfdeee | ||
|
|
f5be09c123 | ||
|
|
23321c4588 | ||
|
|
e1f5043489 | ||
|
|
5d9a2564e4 | ||
|
|
63bce1f01a | ||
|
|
84ce1ea147 | ||
|
|
4090d9f2b3 | ||
|
|
eba74fa6e2 | ||
|
|
8627bce1a1 | ||
|
|
d50caf231b | ||
|
|
f396200d38 | ||
|
|
6c04c37294 | ||
|
|
de23e5199d | ||
|
|
34aa5e995b | ||
|
|
c8dac10348 | ||
|
|
aab4cd086f | ||
|
|
6208c243f9 | ||
|
|
3a87e746a8 | ||
|
|
cd04206334 | ||
|
|
2768080edf | ||
|
|
466cc4cecd | ||
|
|
fe90c49795 | ||
|
|
31418ca821 | ||
|
|
0ee5777c15 | ||
|
|
026213aab7 | ||
|
|
0cab43fb43 | ||
|
|
1341cd4078 | ||
|
|
4059202a5c | ||
|
|
d24d8806e1 | ||
|
|
a67eb15ad3 | ||
|
|
9e182a595c | ||
|
|
4587dc2d64 | ||
|
|
00fccd87f9 | ||
|
|
01bd4cfb73 | ||
|
|
125c1b7f13 | ||
|
|
d6cf67b6bc | ||
|
|
77f378dd53 | ||
|
|
16dcafa7fb | ||
|
|
4ac74edf38 | ||
|
|
69495dcd98 | ||
|
|
288d7a3e7b | ||
|
|
4990bf51e3 | ||
|
|
b2c8cbb78d | ||
|
|
8061f83704 | ||
|
|
a3b49b9028 | ||
|
|
17945884a5 | ||
|
|
1116f06139 | ||
|
|
c728cfdaf4 | ||
|
|
9322bcdb21 | ||
|
|
2f19a1d7a4 | ||
|
|
71c8143f72 | ||
|
|
222d3e6b62 | ||
|
|
2e6519bb98 | ||
|
|
726d539174 | ||
|
|
e7ee4bea18 | ||
|
|
efd2613bd7 | ||
|
|
f88bc732cc | ||
|
|
0746f8cfff | ||
|
|
0ac95e370b | ||
|
|
283ccb83d1 | ||
|
|
5319bc7c2c | ||
|
|
db694f2f24 | ||
|
|
814f1289f6 | ||
|
|
0b7556b590 | ||
|
|
f7e4ac3e43 | ||
|
|
c36ae159ed | ||
|
|
33e7f16c05 | ||
|
|
d3a86841b9 | ||
|
|
e14b8d385a | ||
|
|
8ce1a872eb | ||
|
|
abfa346630 | ||
|
|
2ffe2d829c | ||
|
|
bfaf6c6b20 | ||
|
|
d293d654a0 | ||
|
|
8384659608 | ||
|
|
a632d0133f | ||
|
|
f982b58d61 | ||
|
|
efb0b299b2 | ||
|
|
eb80d3141d | ||
|
|
3604c1255d | ||
|
|
22d63fa69c | ||
|
|
fc030d2d19 | ||
|
|
4a534f02a4 | ||
|
|
0ea408e12b | ||
|
|
644d0d4a15 | ||
|
|
f04746a928 | ||
|
|
1be2529fb9 | ||
|
|
959756abf1 | ||
|
|
c88b802e32 | ||
|
|
9f74fc11de | ||
|
|
2ef09d17b9 | ||
|
|
19d070c284 | ||
|
|
e24db2994b | ||
|
|
ac7a58cd45 | ||
|
|
53c21eb30d | ||
|
|
e51e6cad84 | ||
|
|
4890f7084f | ||
|
|
3fcbfe4370 | ||
|
|
da808cb43b | ||
|
|
82580c6798 | ||
|
|
016e68ec53 | ||
|
|
79f469ce71 | ||
|
|
0501e177e9 |
@@ -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
|
||||
|
||||
|
||||
@@ -75,11 +75,11 @@ body:
|
||||
- type: checkboxes
|
||||
id: mui
|
||||
attributes:
|
||||
label: Is this bug report about any UI component firmware like InkHUD or Meshtatic UI (MUI)?
|
||||
label: Is this bug report about any UI (https://meshtastic.org/docs/configuration/device-uis/) component firmware?
|
||||
options:
|
||||
- label: Meshtastic UI aka MUI colorTFT
|
||||
- label: InkHUD ePaper
|
||||
- label: OLED slide UI on any display
|
||||
- label: Meshtastic UI aka MUI
|
||||
- label: InkHUD
|
||||
- label: BaseUI
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
|
||||
+113
-18
@@ -13,6 +13,7 @@ Meshtastic is an open-source LoRa mesh networking project for long-range, low-po
|
||||
- **RP2040/RP2350** - Raspberry Pi Pico variants
|
||||
- **STM32WL** - STM32 with integrated LoRa
|
||||
- **Linux/Portduino** - Native Linux builds (Raspberry Pi, etc.)
|
||||
- **macOS native** - Headless `meshtasticd` on Apple Silicon / x86_64; see `variants/native/portduino/platformio.ini` for Homebrew prereqs + CH341 LoRa setup
|
||||
|
||||
### Supported Radio Chips
|
||||
|
||||
@@ -70,6 +71,70 @@ PKI (Public Key Infrastructure) messages have special handling:
|
||||
- Accepted on a special "PKI" channel
|
||||
- Allow encrypted DMs between nodes that discovered each other on downlink-enabled channels
|
||||
|
||||
## Encryption & Key Management
|
||||
|
||||
Meshtastic packets on the air are typically encrypted one of two ways: the **per-channel symmetric** layer (AES-CTR with a shared PSK) for broadcasts and channel traffic, and the **per-peer PKI** layer (X25519 ECDH → AES-256-CCM) for direct messages and remote admin. A channel with a 0-byte PSK (or Ham mode, which wipes PSKs) transmits cleartext — see the size table below. Both are implemented in `src/mesh/CryptoEngine.cpp`; the send/receive dispatch lives in `src/mesh/Router.cpp`; admin authorization lives in `src/modules/AdminModule.cpp`.
|
||||
|
||||
### High-level model
|
||||
|
||||
- **Channels** are symmetric rooms: anyone with the PSK can read any message on the channel. Channel 0 is the "primary" channel and ships with the short-form default PSK on factory devices, forming the public mesh most users join. (The LoRa modem preset `LONG_FAST` lives on `config.lora.modem_preset` and is an independent field — don't conflate "channel 0 default PSK" with the modem preset name.)
|
||||
- **DMs** addressed to a single node require PKI so that other holders of the channel PSK can't read them. Outside Ham mode, Meshtastic does not fall back to channel-symmetric encryption when the destination public key is unknown.
|
||||
- **Remote admin** is a DM carrying an `AdminMessage`. The receiver only acts on it if the sender's public key is on its allowlist (`config.security.admin_key[0..2]`).
|
||||
- **Ham mode** (`owner.is_licensed=true`, where `owner` is the local `meshtastic_User` record) disables PKI entirely and sends cleartext — FCC Part 97 prohibits encryption on amateur bands.
|
||||
- **No ratchet, no session.** Every packet is encrypted from scratch — a stateless design that matches the high-loss, store-and-forward nature of LoRa.
|
||||
|
||||
### Symmetric channel encryption (AES-CTR)
|
||||
|
||||
`CryptoEngine::encryptPacket` / `decrypt` / `encryptAESCtr` in `src/mesh/CryptoEngine.cpp`.
|
||||
|
||||
- **Cipher**: AES-CTR, AES-128 or AES-256 depending on key length. Same routine in both directions (CTR is a stream cipher, so encrypt == decrypt).
|
||||
- **Key**: `ChannelSettings.psk` bytes. Size semantics:
|
||||
- **0 bytes** → no encryption, cleartext on the air
|
||||
- **1 byte** → short-form index into the well-known `defaultpsk[]` in `src/mesh/Channels.h`. Index 0 = cleartext; 1 = defaultpsk unchanged; 2..255 = defaultpsk with its last byte incremented by (index − 1). This is what the CLI's `--ch-set psk default` produces.
|
||||
- **16 bytes** → raw AES-128 key
|
||||
- **32 bytes** → raw AES-256 key
|
||||
- **2..15 bytes** → zero-padded to 16 and used as AES-128 (with a warn log); **17..31 bytes** → zero-padded to 32 and used as AES-256 (with a warn log). Defensive fallback for malformed PSK input, not something to rely on.
|
||||
- **Nonce (128 bit)**: `packet_id` (u64 LE) ‖ `from_node` (u32 LE) ‖ `block_counter` (u32, starts at 0). Built in `CryptoEngine::initNonce`.
|
||||
- **No AEAD**: channel packets carry no MAC, so the channel-hash byte is not an integrity or authenticity check. `Channels::getHash` is a 1-byte XOR-derived hint over the channel name bytes and PSK bytes that helps receivers pick a candidate channel/PSK for decryption. Because it is only a small hint and collisions are easy to find, it should be described purely as a PSK-selection aid, not as a security filter an attacker cannot bypass.
|
||||
- **Channel 0 is special in one way only**: it's the channel the Router attempts PKI decryption on before falling through to AES-CTR. Non-zero channels always go straight to AES-CTR.
|
||||
|
||||
### PKI encryption for DMs (X25519 ECDH + AES-256-CCM)
|
||||
|
||||
`CryptoEngine::encryptCurve25519` / `decryptCurve25519` in `src/mesh/CryptoEngine.cpp`.
|
||||
|
||||
- **Keypair**: Curve25519 (aka X25519), 32-byte public + 32-byte private. Stored in `config.security.public_key` / `private_key`; the public half is mirrored into `owner.public_key` so it rides along in NodeInfo broadcasts and propagates through the mesh like any other identity field.
|
||||
- **Key generation** (`generateKeyPair`): stirs `HardwareRNG::fill()` (64 B from platform TRNG when available), the 16-byte `myNodeInfo.device_id`, and a call to `random()` into the rweather/Crypto library's software RNG, then `Curve25519::dh1`. `regeneratePublicKey` recomputes the public half from a known private (used when restoring from backup).
|
||||
- **Keygen entry points**: at boot, `NodeDB` calls `generateKeyPair` (or `regeneratePublicKey` when a stored private key is present and passes a low-entropy check) **directly** when `!owner.is_licensed` and `config.lora.region != UNSET`. `ensurePkiKeys` wraps the same logic for runtime/admin flows — it's the path `AdminModule::handleSetConfig` runs when first assigning a valid region or when security config is written; **do not assume it's the universal boot-time gate**, because the NodeDB path bypasses it.
|
||||
- **Handshake**: `Curve25519::dh2(local_private, remote_public) → 32-byte shared secret → SHA-256 → 32-byte AES-256 key`. Recomputed per packet. The SHA-256 step is effectively a KDF over the raw ECDH output.
|
||||
- **Cipher**: AES-256-CCM via `aes_ccm_ae` / `aes_ccm_ad` (`src/mesh/aes-ccm.cpp`). MAC length (the `M` parameter) is **8 bytes**. No AAD — the MAC covers ciphertext only.
|
||||
- **Nonce (13 bytes / 104 bit)**: `aes_ccm_ae`/`aes_ccm_ad` use a 13-byte CCM nonce (`L = 2` is hardcoded in `src/mesh/aes-ccm.cpp`), not a 16-byte nonce. For PKI packets, `CryptoEngine::initNonce(fromNode, packetNum, extraNonce)` starts from the usual packet-derived nonce material, then overwrites nonce bytes `4..7` with a fresh 32-bit `extraNonce = random()`. The effective nonce bytes are therefore: bytes `0..3` = `packet_id`, bytes `4..7` = transmitted `extraNonce`, bytes `8..11` = `from_node`, byte `12` = `0x00`. The receiver reconstructs the same 13-byte nonce from the packet metadata plus the appended `extraNonce`.
|
||||
- **Wire overhead**: 12 bytes appended to the ciphertext = 8-byte MAC ‖ 4-byte extraNonce. Defined as `MESHTASTIC_PKC_OVERHEAD = 12` in `src/mesh/RadioInterface.h`. Only the 4-byte `extraNonce` is sent; the rest of the 13-byte CCM nonce is reconstructed from packet fields as described above. The Router's send path checks this overhead against `MAX_LORA_PAYLOAD_LEN` before committing to PKI.
|
||||
- **Send selection** (`Router::send`): the sender enters the PKI path when **all** hold — we're the originator AND not Ham mode AND not Portduino simradio AND not on the `serial`/`gpio` channels (unless the packet is already marked `pki_encrypted`) AND `config.security.private_key.size == 32` AND destination is a single node (not broadcast) AND the portnum isn't infrastructure. `TRACEROUTE_APP`, `NODEINFO_APP`, `ROUTING_APP`, and `POSITION_APP` are routed through channel encryption even when DMed (these need to be readable by relaying peers). Once on the PKI path, if the destination's public key isn't in our NodeDB the send **fails** with `PKI_SEND_FAIL_PUBLIC_KEY` — it does not silently fall back to channel encryption. If the client explicitly set `pki_encrypted=true` and any condition blocks PKI, the send fails with `PKI_FAILED`.
|
||||
- **Receive selection** (`Router::perhapsDecode`): try PKI decrypt first when `channel == 0` AND `isToUs(p)` AND not broadcast AND both peers have public keys in NodeDB AND `rawSize > MESHTASTIC_PKC_OVERHEAD`. On success the packet gets `pki_encrypted=true` stamped and the sender's public key copied into `p->public_key` for downstream authorization.
|
||||
|
||||
### Remote admin authorization
|
||||
|
||||
Implemented in `src/modules/AdminModule.cpp` → `handleReceivedProtobuf`. The authorization check runs in this order:
|
||||
|
||||
1. **Response messages** — if `messageIsResponse(r)` is true (the payload is a response to one of our earlier admin requests), it's accepted without any further check. The in-file comment flags this as a known-untightened gap: a stricter implementation would remember which `public_key` we last queried and reject responses that don't match.
|
||||
2. **Local admin** — `mp.from == 0` (phone app over BLE, serial CLI, internal module); never travels over the air. **Rejected** if `config.security.is_managed` is true, because managed devices expect admin to arrive over the air through an authorized remote path.
|
||||
3. **Legacy admin channel (deprecated)** — the packet arrived on a channel named literally `"admin"`. Gated by `config.security.admin_channel_enabled`; returns `NOT_AUTHORIZED` if the flag is false. Kept for backward compatibility; new deployments should use PKI admin.
|
||||
4. **PKI admin (preferred for remote)** — `mp.pki_encrypted == true` AND `mp.public_key` matches one of `config.security.admin_key[0..2]` (up to three authorized 32-byte Curve25519 public keys, typically copied from the admin node's own `user.public_key`).
|
||||
5. **Fallthrough** → `NOT_AUTHORIZED`.
|
||||
|
||||
On top of authorization, any remote admin message that **mutates** state (not a request, not a response) also has to pass a session-key check (`checkPassKey`): the client must first pull a fresh 8-byte `session_passkey` via `get_admin_session_key_request`, then echo that passkey back in the mutating message. The device rotates the passkey after 150 s and rejects values older than 300 s — a narrow anti-replay window on top of the PKI layer.
|
||||
|
||||
`config.security.is_managed = true` disables **local** admin writes (`mp.from == 0` is rejected). It does not by itself force every admin action through PKI — the legacy `"admin"` channel still authorizes remote admin when `config.security.admin_channel_enabled == true`. The AdminModule refuses to persist `is_managed=true` unless at least one `admin_key` is populated — a deliberate guard against operators locking themselves out.
|
||||
|
||||
### Key-rotation hazards (actions that invalidate peers)
|
||||
|
||||
- **`factory_reset_device`** (the "full" variant, calls `NodeDB::factoryReset(eraseBleBonds=true)`) → **wipes** the X25519 private key; a fresh keypair is generated on the next region-set. Every existing peer holds the old public key, so DMs to this node silently fail PKI decrypt until every peer re-exchanges NodeInfo.
|
||||
- **`factory_reset_config`** (the "partial" variant, calls `NodeDB::factoryReset()` with `eraseBleBonds=false`) → **preserves** the X25519 private key in `installDefaultConfig(preserveKey=true)`; the public key is zeroed and gets rebuilt from the preserved private key on the next boot via the NodeDB path's `regeneratePublicKey` call. Identity is preserved and the mesh does not need to re-exchange keys.
|
||||
- **`region=UNSET → valid region`** → `ensurePkiKeys` runs inside the same `handleSetConfig` path; missing keys get generated at that moment.
|
||||
- **Ham mode transitions** — entering Ham mode (`user.is_licensed=true`) runs `Channels::ensureLicensedOperation`, which **wipes every channel PSK** (all traffic becomes cleartext) and disables the legacy admin channel. The X25519 private key is preserved on the device but not used because `Router::send` skips PKI when `owner.is_licensed` is true. Leaving Ham mode re-enables PKI with the preserved keypair but does not restore the wiped channel PSKs — the operator has to re-set them.
|
||||
- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast.
|
||||
- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -80,7 +145,7 @@ firmware/
|
||||
│ │ ├── NodeDB.* # Node database management
|
||||
│ │ ├── Router.* # Packet routing
|
||||
│ │ ├── Channels.* # Channel management
|
||||
│ │ ├── CryptoEngine.* # AES-CCM encryption
|
||||
│ │ ├── CryptoEngine.* # AES-CTR (channels) + X25519 ECDH→AES-256-CCM (PKI for DMs/admin)
|
||||
│ │ ├── *Interface.* # Radio interface implementations
|
||||
│ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI)
|
||||
│ │ ├── http/ # HTTP server (WebServer, ContentHandler)
|
||||
@@ -296,6 +361,23 @@ Key defines in variant.h:
|
||||
|
||||
## Build System
|
||||
|
||||
## Agent Tooling Baseline
|
||||
|
||||
Mirror counterpart: `AGENTS.md` under **Agent Tooling Baseline**.
|
||||
|
||||
To reduce avoidable agent mistakes, assume these tools are available (or install them before significant repo work):
|
||||
|
||||
- **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs`
|
||||
- **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing
|
||||
- **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`)
|
||||
- **Containerized native testing**: `docker` (fallback for non-Linux hosts; macOS can also build natively via `pio run -e native-macos`)
|
||||
|
||||
Fallback expectations for agents:
|
||||
|
||||
- If `rg` is unavailable, use `find` + `grep` instead of failing.
|
||||
- For native tests on hosts without Linux deps, prefer `./bin/test-native-docker.sh`.
|
||||
- The simulator helper script is `./bin/test-simulator.sh`.
|
||||
|
||||
Uses **PlatformIO** with custom scripts:
|
||||
|
||||
- `bin/platformio-pre.py` - Pre-build script
|
||||
@@ -307,6 +389,7 @@ Build commands:
|
||||
pio run -e tbeam # Build specific target
|
||||
pio run -e tbeam -t upload # Build and upload
|
||||
pio run -e native # Build native/Linux version
|
||||
pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prereqs in variants/native/portduino/platformio.ini)
|
||||
```
|
||||
|
||||
### Build Manifest
|
||||
@@ -448,6 +531,8 @@ Run with: `pio test -e native`
|
||||
|
||||
Simulation testing: `bin/test-simulator.sh`
|
||||
|
||||
Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist).
|
||||
|
||||
### Hardware-in-the-loop tests (`mcp-server/tests/`)
|
||||
|
||||
Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules.
|
||||
@@ -474,7 +559,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 +567,15 @@ 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`.
|
||||
|
||||
**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes".
|
||||
|
||||
### Hardware test suite (`mcp-server/run-tests.sh`)
|
||||
|
||||
@@ -494,14 +583,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 +677,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
|
||||
|
||||
@@ -32,10 +32,15 @@ jobs:
|
||||
shell: bash
|
||||
working-directory: meshtasticd
|
||||
run: |
|
||||
# Build-tools (notably platformio) come from the Meshtastic project
|
||||
# on the OpenSUSE Build Service:
|
||||
# https://build.opensuse.org/project/show/network:Meshtastic:build-tools
|
||||
echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/build-tools/xUbuntu_24.04/ /' \
|
||||
| sudo tee /etc/apt/sources.list.d/network:Meshtastic:build-tools.list
|
||||
curl -fsSL https://download.opensuse.org/repositories/network:Meshtastic:build-tools/xUbuntu_24.04/Release.key \
|
||||
| gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/network_Meshtastic_build-tools.gpg >/dev/null
|
||||
sudo apt-get update -y --fix-missing
|
||||
sudo apt-get install -y software-properties-common build-essential devscripts equivs
|
||||
sudo add-apt-repository ppa:meshtastic/build-tools -y
|
||||
sudo apt-get update -y --fix-missing
|
||||
sudo apt-get install -y build-essential devscripts equivs
|
||||
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
|
||||
|
||||
- name: Import GPG key
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Build MacOS Binary
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
macos_ver:
|
||||
required: false
|
||||
default: "26" # ARM64
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-MacOS:
|
||||
runs-on: macos-${{ inputs.macos_ver }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install deps
|
||||
shell: bash
|
||||
run: |
|
||||
brew update
|
||||
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius
|
||||
|
||||
- name: Get release version string
|
||||
run: |
|
||||
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
id: version
|
||||
|
||||
- name: Build for MacOS
|
||||
run: |
|
||||
platformio run -e native-macos
|
||||
env:
|
||||
PKG_VERSION: ${{ steps.version.outputs.long }}
|
||||
# Errors in this step should not fail the entire workflow while MacOS support is in development.
|
||||
continue-on-error: true
|
||||
|
||||
- name: List output files
|
||||
run: ls -lah .pio/build/native-macos/
|
||||
|
||||
- name: Store binaries as an artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: firmware-macos-${{ inputs.macos_ver }}-${{ steps.version.outputs.long }}
|
||||
overwrite: true
|
||||
path: |
|
||||
.pio/build/native-macos/meshtasticd
|
||||
@@ -4,9 +4,14 @@ 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
|
||||
@@ -15,32 +20,18 @@ on:
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
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'
|
||||
description: Choose an arch to limit the search, or 'all' to search all architectures.
|
||||
default: all
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
find-targets:
|
||||
if: ${{ inputs.target == '' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
- esp32c6
|
||||
- nrf52840
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
- all
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -51,14 +42,37 @@ 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)
|
||||
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
|
||||
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 }}
|
||||
|
||||
version:
|
||||
if: ${{ inputs.target != '' }}
|
||||
@@ -78,12 +92,12 @@ jobs:
|
||||
|
||||
build:
|
||||
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
||||
needs: [version]
|
||||
needs: [version, find-targets]
|
||||
uses: ./.github/workflows/build_firmware.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.long }}
|
||||
pio_env: ${{ inputs.target }}
|
||||
platform: ${{ inputs.arch }}
|
||||
platform: ${{ needs.find-targets.outputs.arch }}
|
||||
|
||||
gather-artifacts:
|
||||
permissions:
|
||||
|
||||
@@ -73,7 +73,9 @@ jobs:
|
||||
- name: Sanitize platform string
|
||||
id: sanitize_platform
|
||||
# Replace slashes with underscores
|
||||
run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
|
||||
env:
|
||||
plat: ${{ inputs.platform }}
|
||||
run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker login
|
||||
if: ${{ inputs.push }}
|
||||
|
||||
@@ -43,6 +43,15 @@ jobs:
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-debian-riscv64:
|
||||
uses: ./.github/workflows/docker_build.yml
|
||||
with:
|
||||
distro: debian
|
||||
platform: linux/riscv64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-alpine-amd64:
|
||||
uses: ./.github/workflows/docker_build.yml
|
||||
with:
|
||||
@@ -70,16 +79,27 @@ jobs:
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-alpine-riscv64:
|
||||
uses: ./.github/workflows/docker_build.yml
|
||||
with:
|
||||
distro: alpine
|
||||
platform: linux/riscv64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-manifest:
|
||||
needs:
|
||||
# Debian
|
||||
- docker-debian-amd64
|
||||
- docker-debian-arm64
|
||||
- docker-debian-armv7
|
||||
- docker-debian-riscv64
|
||||
# Alpine
|
||||
- docker-alpine-amd64
|
||||
- docker-alpine-arm64
|
||||
- docker-alpine-armv7
|
||||
- docker-alpine-riscv64
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -162,6 +182,7 @@ jobs:
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-amd64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-arm64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-armv7.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-riscv64.outputs.digest }}
|
||||
|
||||
- name: Docker meta (Alpine)
|
||||
id: meta_alpine
|
||||
@@ -182,3 +203,4 @@ jobs:
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-amd64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-arm64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-armv7.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-riscv64.outputs.digest }}
|
||||
|
||||
@@ -116,6 +116,20 @@ jobs:
|
||||
build_location: local
|
||||
secrets: inherit
|
||||
|
||||
MacOS:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
macos_ver:
|
||||
- "26" # ARM64
|
||||
# - '26-intel' # x86_64
|
||||
- "15" # ARM64
|
||||
# - '15-intel' # x86_64
|
||||
uses: ./.github/workflows/build_macos_bin.yml
|
||||
with:
|
||||
macos_ver: ${{ matrix.macos_ver }}
|
||||
# secrets: inherit
|
||||
|
||||
package-pio-deps-native-tft:
|
||||
if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/package_pio_deps.yml
|
||||
@@ -286,6 +300,7 @@ jobs:
|
||||
- gather-artifacts
|
||||
- build-debian-src
|
||||
- package-pio-deps-native-tft
|
||||
# - MacOS
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -86,7 +86,13 @@ jobs:
|
||||
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
||||
|
||||
- name: PlatformIO Tests
|
||||
run: platformio test -e coverage -v --junit-output-path testreport.xml
|
||||
run: |
|
||||
set -o pipefail
|
||||
# Filter out SKIPPED summary rows for hardware variants that can't run on the
|
||||
# native host. They flood the log and make it harder to spot real failures.
|
||||
# The JUnit XML is written directly to testreport.xml before the pipe, so
|
||||
# the test artifact is unaffected.
|
||||
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
|
||||
|
||||
- name: Save test results
|
||||
if: always() # run this step even if previous step failed
|
||||
|
||||
+9
-9
@@ -4,22 +4,22 @@ cli:
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.7.6
|
||||
ref: v1.8.0
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
enabled:
|
||||
- checkov@3.2.517
|
||||
- renovate@43.110.9
|
||||
- prettier@3.8.1
|
||||
- trufflehog@3.94.3
|
||||
- checkov@3.2.526
|
||||
- renovate@43.150.0
|
||||
- prettier@3.8.3
|
||||
- trufflehog@3.95.2
|
||||
- yamllint@1.38.0
|
||||
- bandit@1.9.4
|
||||
- trivy@0.69.3
|
||||
- trivy@0.70.0
|
||||
- taplo@0.10.0
|
||||
- ruff@0.15.9
|
||||
- ruff@0.15.12
|
||||
- isort@8.0.1
|
||||
- markdownlint@0.48.0
|
||||
- oxipng@10.1.0
|
||||
- oxipng@10.1.1
|
||||
- svgo@4.0.1
|
||||
- actionlint@1.7.12
|
||||
- flake8@7.3.0
|
||||
@@ -36,7 +36,7 @@ lint:
|
||||
- bin/**
|
||||
runtimes:
|
||||
enabled:
|
||||
- python@3.10.8
|
||||
- python@3.14.4
|
||||
- go@1.21.0
|
||||
- node@22.16.0
|
||||
actions:
|
||||
|
||||
@@ -10,17 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
|
||||
|
||||
## Quick command reference
|
||||
|
||||
| Action | Command |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `pio test -e native` |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
| Action | Command |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `pio test -e native` |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
|
||||
## MCP server (device + test automation)
|
||||
|
||||
@@ -48,6 +49,15 @@ Three test-and-diagnose workflows exist as slash commands:
|
||||
|
||||
Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index.
|
||||
|
||||
## Encryption at a glance
|
||||
|
||||
Two layers, both in `src/mesh/CryptoEngine.cpp`:
|
||||
|
||||
- **Channel (symmetric)** — **AES-CTR** with a channel-wide PSK (AES-128 or AES-256). Nonce = packet_id ‖ from_node ‖ block_counter. No AEAD; integrity is soft (channel-hash filter). The well-known default PSK lives in `src/mesh/Channels.h`; a 1-byte PSK is a short-form index into it.
|
||||
- **Per-peer PKI** — **X25519 ECDH** (Curve25519, 32-byte keys) → SHA-256 → **AES-256-CCM** with an 8-byte MAC. Fresh 32-bit `extraNonce` per packet, sent in the clear alongside the MAC. 12-byte wire overhead (`MESHTASTIC_PKC_OVERHEAD`). Used for DMs. Also used for remote admin (`src/modules/AdminModule.cpp`), where AdminMessage authorization is gated by `config.security.admin_key[0..2]`. Disabled entirely in Ham mode (`user.is_licensed=true`).
|
||||
|
||||
Key rotation to never trigger casually: only the **full** factory reset (`factory_reset_device`, `eraseBleBonds=true`) wipes `security.private_key` and regenerates the keypair — every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The **partial** config reset (`factory_reset_config`) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking `security.private_key` via admin also triggers regen. See the **Encryption & Key Management** section of `.github/copilot-instructions.md` for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, `is_managed` scope, key-rotation hazards).
|
||||
|
||||
## House rules
|
||||
|
||||
- **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes.
|
||||
@@ -89,25 +99,44 @@ 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.
|
||||
- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b <bundleID>`. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit.
|
||||
|
||||
## 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_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. |
|
||||
| `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`. |
|
||||
|
||||
+3
-1
@@ -3,15 +3,17 @@
|
||||
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
FROM python:3.14-slim-trixie AS builder
|
||||
FROM debian:trixie AS builder
|
||||
ARG PIO_ENV=native
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
# Install Dependencies
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
curl wget g++ zip git ca-certificates pkg-config \
|
||||
python3-pip python3-grpc-tools \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
|
||||
+10
-3
@@ -3,12 +3,19 @@
|
||||
# trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
FROM python:3.14-alpine3.22 AS builder
|
||||
# Ensure the Alpine version is updated in both stages of the container!
|
||||
FROM alpine:3.23 AS builder
|
||||
ARG PIO_ENV=native
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# Enable Alpine community repository (for 'py3-grpcio-tools')
|
||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v$(cut -d. -f1,2 /etc/alpine-release)/community" >> /etc/apk/repositories
|
||||
|
||||
# Install Dependencies
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apk --no-cache add \
|
||||
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
||||
py3-pip py3-grpcio-tools \
|
||||
libgpiod-dev yaml-cpp-dev bluez-dev \
|
||||
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
||||
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
||||
@@ -60,4 +67,4 @@ EXPOSE 4403
|
||||
|
||||
CMD [ "sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd" ]
|
||||
|
||||
HEALTHCHECK NONE
|
||||
HEALTHCHECK NONE
|
||||
|
||||
+2
-1
@@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION
|
||||
pio pkg install --environment "$PIO_ENV" || platformioFailed
|
||||
pio run --environment "$PIO_ENV" || platformioFailed
|
||||
|
||||
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)"
|
||||
os_name=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)"
|
||||
cp bin/native-install.* $OUTDIR/
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to show commits in develop that are not in master
|
||||
# with their associated PR info and commit hashes
|
||||
#
|
||||
# Usage:
|
||||
# ./show-unmerged-prs.sh # Show all unmerged commits
|
||||
# ./show-unmerged-prs.sh --bugfix # Show only bugfix-labeled PRs
|
||||
|
||||
set -e
|
||||
|
||||
REPO="firmware"
|
||||
OWNER="meshtastic"
|
||||
BASE_BRANCH="master"
|
||||
HEAD_BRANCH="develop"
|
||||
LIMIT=100
|
||||
FILTER_LABEL=""
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--bugfix)
|
||||
FILTER_LABEL="bugfix"
|
||||
shift
|
||||
;;
|
||||
--feature)
|
||||
FILTER_LABEL="feature"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo "Options:"
|
||||
echo " --bugfix Show only PRs labeled with 'bugfix'"
|
||||
echo " --feature Show only PRs labeled with 'feature'"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -n "$FILTER_LABEL" ]; then
|
||||
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH (filtered by label: $FILTER_LABEL)..."
|
||||
else
|
||||
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check if gh CLI is available
|
||||
if ! command -v gh &> /dev/null; then
|
||||
echo "ERROR: GitHub CLI (gh) not found. Please install it first."
|
||||
echo "Visit: https://cli.github.com/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get commits in develop that are not in master
|
||||
# For each commit, try to find associated PR
|
||||
git fetch origin develop master 2>/dev/null || true
|
||||
|
||||
# Use git to get the list of commits
|
||||
commits=$(git log --pretty=format:"%H|%s" origin/master..origin/develop | head -n $LIMIT)
|
||||
|
||||
count=0
|
||||
displayed=0
|
||||
echo "Commits in $HEAD_BRANCH not in $BASE_BRANCH:"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
while IFS='|' read -r hash subject; do
|
||||
((count++))
|
||||
|
||||
# Try to find the PR for this commit
|
||||
# Extract PR number, title, description, and labels
|
||||
pr_response=$(gh api -X GET "/repos/$OWNER/$REPO/commits/$hash/pulls" \
|
||||
-H "Accept: application/vnd.github.v3+json" 2>/dev/null | \
|
||||
jq -r '.[0] | "\(.number)|\(.title)|\(.body // "No description")|\(.labels | map(.name) | join(","))"' 2>/dev/null || echo "||||")
|
||||
|
||||
if [ -z "$pr_response" ] || [ "$pr_response" = "||||" ]; then
|
||||
# If no PR found, skip if filter is active, otherwise show the commit
|
||||
if [ -z "$FILTER_LABEL" ]; then
|
||||
((displayed++))
|
||||
echo "[$displayed] Commit: $hash"
|
||||
echo " Subject: $subject"
|
||||
echo " PR: Not found in GitHub"
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
IFS='|' read -r pr_num pr_title pr_desc pr_labels <<< "$pr_response"
|
||||
|
||||
# Check if filter matches
|
||||
if [ -n "$FILTER_LABEL" ]; then
|
||||
# Only show if the label is in the labels list
|
||||
if ! echo "$pr_labels" | grep -q "$FILTER_LABEL"; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
((displayed++))
|
||||
echo "[$displayed] PR #$pr_num - $pr_title"
|
||||
echo " Commit: $hash"
|
||||
if [ -n "$pr_desc" ] && [ "$pr_desc" != "No description" ]; then
|
||||
# Truncate description to 200 chars
|
||||
desc_short="${pr_desc:0:200}"
|
||||
[ ${#pr_desc} -gt 200 ] && desc_short+="..."
|
||||
echo " Description: $desc_short"
|
||||
fi
|
||||
if [ -n "$pr_labels" ] && [ "$pr_labels" != "" ]; then
|
||||
echo " Labels: $pr_labels"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
done <<< "$commits"
|
||||
|
||||
echo ""
|
||||
if [ -n "$FILTER_LABEL" ]; then
|
||||
echo "Done. Showing $displayed PRs with label '$FILTER_LABEL' from $displayed commits checked."
|
||||
else
|
||||
echo "Done. Showing $displayed commits from $HEAD_BRANCH not in $BASE_BRANCH."
|
||||
fi
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "heltec_v4_r8"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "heltec"
|
||||
}
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/bash
|
||||
set -e
|
||||
export DEBEMAIL="jbennett@incomsystems.biz"
|
||||
export PLATFORMIO_LIBDEPS_DIR=pio/libdeps
|
||||
export PLATFORMIO_PACKAGES_DIR=pio/packages
|
||||
|
||||
@@ -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/
|
||||
|
||||
+153
-11
@@ -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.
|
||||
@@ -138,15 +166,73 @@ _The tool tables below document 38 currently registered MCP server tools._
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
||||
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
||||
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
||||
| Var | Default | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
||||
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
||||
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
||||
| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
|
||||
|
||||
## TCP / native-host nodes
|
||||
|
||||
The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd`
|
||||
binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino).
|
||||
The daemon exposes the meshtastic TCP API on port `4403` rather than a USB
|
||||
serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`:
|
||||
|
||||
```bash
|
||||
# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini
|
||||
# for full Homebrew prereqs and CH341 LoRa-adapter setup).
|
||||
pio run -e native-macos
|
||||
~/.meshtasticd/meshtasticd
|
||||
|
||||
# 2. Point the MCP server at it.
|
||||
export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403
|
||||
```
|
||||
|
||||
**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the
|
||||
USB adapter's serial-number / product strings. Many cheap CH341 dongles
|
||||
(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0`
|
||||
and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address
|
||||
not allowed!`. Set the MAC explicitly in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# Under General:
|
||||
MACAddress: 02:CA:FE:BA:BE:01
|
||||
```
|
||||
|
||||
Use a locally-administered address (first byte's second-LSB set, e.g.
|
||||
`02:*` / `06:*` / `0A:*` / `0E:*`) to avoid colliding with a real OUI.
|
||||
|
||||
There is also a `--hwid AA:BB:CC:DD:EE:FF` CLI flag visible in
|
||||
`meshtasticd --help`, but it is **currently broken** in
|
||||
`MAC_from_string()` (`src/platform/portduino/PortduinoGlue.cpp`): the
|
||||
function strips colons from its parameter but then reads bytes from the
|
||||
global `portduino_config.mac_address`, so `--hwid` is silently overridden
|
||||
when `MACAddress:` is also set, and crashes the daemon (uncaught
|
||||
`std::invalid_argument: stoi: no conversion`) when it isn't. Use the YAML
|
||||
form until that's fixed upstream.
|
||||
|
||||
`list_devices` will surface the daemon as `tcp://localhost:4403` with
|
||||
`likely_meshtastic=True`, so `device_info`, `list_nodes`, `get_config`,
|
||||
`set_config`, `set_owner`, `send_text`, `userprefs_*`, and the admin RPCs
|
||||
auto-select it when no `port` is passed. Pass `port="tcp://other-host:9999"`
|
||||
explicitly to target a different daemon.
|
||||
|
||||
**Tools that don't apply to a TCP/native node** (no USB hardware to operate
|
||||
on) raise a clear `ConnectionError` rather than failing mysteriously:
|
||||
`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`,
|
||||
`serial_open` (use info/admin tools directly), and the vendor escape hatches
|
||||
`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env
|
||||
similarly raises — there's no upload step; use `build` and run the binary
|
||||
directly.
|
||||
|
||||
The pytest harness in `tests/` still assumes USB-attached devices per role —
|
||||
TCP-aware fixtures are not part of this surface yet.
|
||||
|
||||
## Hardware Test Suite
|
||||
|
||||
@@ -182,10 +268,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 +291,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 +351,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",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Context manager for meshtastic.SerialInterface connections.
|
||||
"""Context manager for meshtastic interface connections (serial + TCP).
|
||||
|
||||
Every info/admin tool goes through `connect(port)` so we have a single place
|
||||
that:
|
||||
@@ -6,8 +6,16 @@ that:
|
||||
- fails fast if a serial_session is already holding the port,
|
||||
- guarantees `.close()` is called, even on exception.
|
||||
|
||||
The `SerialInterface` blocks on construction waiting for the node database;
|
||||
that's fine for v1 since every tool is a short-lived request.
|
||||
Two transports:
|
||||
- Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`.
|
||||
- TCP: a `meshtasticd` daemon (e.g. the native macOS / Linux Portduino
|
||||
headless build) addressed as `tcp://host[:port]` (default port 4403).
|
||||
Surfaced by `devices.list_devices()` when `MESHTASTIC_MCP_TCP_HOST` is
|
||||
set, so `resolve_port(None)` auto-selects it like a USB candidate.
|
||||
|
||||
Both `SerialInterface` and `TCPInterface` block on construction waiting for
|
||||
the node database; that's fine for v1 since every tool is a short-lived
|
||||
request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,20 +25,107 @@ from typing import Iterator
|
||||
|
||||
from . import devices, registry
|
||||
|
||||
DEFAULT_TCP_PORT = 4403
|
||||
TCP_SCHEME = "tcp://"
|
||||
TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST"
|
||||
|
||||
|
||||
class ConnectionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def is_tcp_port(port: str | None) -> bool:
|
||||
return bool(port) and port.startswith(TCP_SCHEME)
|
||||
|
||||
|
||||
def parse_tcp_port(port: str) -> tuple[str, int]:
|
||||
"""Parse `tcp://host[:port]` → (host, port). Defaults to 4403.
|
||||
|
||||
Validates host shape (non-empty, no path separators) and port range
|
||||
(1..65535). Raises `ConnectionError` on malformed input — never lets
|
||||
a raw `ValueError` bubble up to a tool surface.
|
||||
"""
|
||||
if not port.startswith(TCP_SCHEME):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: expected '{TCP_SCHEME}host[:port]'."
|
||||
)
|
||||
rest = port[len(TCP_SCHEME) :]
|
||||
if ":" in rest:
|
||||
host, port_str = rest.rsplit(":", 1)
|
||||
try:
|
||||
tcp_port = int(port_str)
|
||||
except ValueError as e:
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: port {port_str!r} is not an integer."
|
||||
) from e
|
||||
else:
|
||||
host, tcp_port = rest, DEFAULT_TCP_PORT
|
||||
if not host:
|
||||
raise ConnectionError(f"Invalid TCP endpoint {port!r}: empty host.")
|
||||
if any(c in host for c in ("/", "\\")):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: host {host!r} contains a path "
|
||||
"separator. TCP hostnames cannot contain '/' or '\\' — did you "
|
||||
"pass a serial port path or a Windows drive path by mistake?"
|
||||
)
|
||||
if not (1 <= tcp_port <= 65535):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: port {tcp_port} out of range "
|
||||
"(must be 1..65535)."
|
||||
)
|
||||
return host, tcp_port
|
||||
|
||||
|
||||
def normalize_tcp_endpoint(endpoint: str) -> str:
|
||||
r"""Normalize `host`, `host:port`, or `tcp://host[:port]` → canonical
|
||||
`tcp://host:port` form. One place that owns the lock-key shape.
|
||||
|
||||
Defers all validation to `parse_tcp_port`, so path-like inputs
|
||||
(`/dev/cu.foo`, `C:\Windows\…`), empty hosts, non-integer ports,
|
||||
and out-of-range ports raise `ConnectionError` here too.
|
||||
"""
|
||||
if endpoint.startswith(TCP_SCHEME):
|
||||
canonical = endpoint
|
||||
elif ":" in endpoint:
|
||||
canonical = f"{TCP_SCHEME}{endpoint}"
|
||||
else:
|
||||
canonical = f"{TCP_SCHEME}{endpoint}:{DEFAULT_TCP_PORT}"
|
||||
host, port = parse_tcp_port(canonical)
|
||||
return f"{TCP_SCHEME}{host}:{port}"
|
||||
|
||||
|
||||
def reject_if_tcp(port: str | None, tool_name: str) -> None:
|
||||
"""Raise if `port` is a TCP endpoint — for tools that need real USB
|
||||
hardware (flash, bootloader, vendor escape hatches, serial monitor).
|
||||
|
||||
Only checks the explicit arg; auto-selection via env var is the caller's
|
||||
responsibility to handle if it matters.
|
||||
"""
|
||||
if is_tcp_port(port):
|
||||
raise ConnectionError(
|
||||
f"{tool_name} is not applicable to TCP/native nodes ({port}). "
|
||||
"This tool requires USB-attached hardware."
|
||||
)
|
||||
|
||||
|
||||
def resolve_port(port: str | None) -> str:
|
||||
"""Pick a port: explicit > sole likely_meshtastic candidate > error."""
|
||||
"""Pick a port: explicit > sole likely_meshtastic candidate > error.
|
||||
|
||||
A `tcp://` string passes through (after canonicalization). When `port`
|
||||
is None and no USB candidates are present, `MESHTASTIC_MCP_TCP_HOST`
|
||||
is consulted via `devices.list_devices()`.
|
||||
"""
|
||||
if port:
|
||||
if is_tcp_port(port):
|
||||
return normalize_tcp_endpoint(port)
|
||||
return port
|
||||
candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]]
|
||||
if not candidates:
|
||||
raise ConnectionError(
|
||||
"No Meshtastic devices detected. Plug one in or pass `port` explicitly. "
|
||||
"Run `list_devices` with include_unknown=True to see all serial ports."
|
||||
"No Meshtastic devices detected. Plug one in, set "
|
||||
f"{TCP_HOST_ENV}=<host[:port]> for a meshtasticd daemon, "
|
||||
"or pass `port` explicitly. Run `list_devices` with "
|
||||
"include_unknown=True to see all serial ports."
|
||||
)
|
||||
if len(candidates) > 1:
|
||||
ports = ", ".join(c["port"] for c in candidates)
|
||||
@@ -43,17 +138,62 @@ def resolve_port(port: str | None) -> str:
|
||||
|
||||
@contextmanager
|
||||
def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
"""Open a `meshtastic.SerialInterface` and always close it.
|
||||
"""Open a meshtastic interface (serial or TCP) and always close it.
|
||||
|
||||
Raises `ConnectionError` immediately if another serial session holds the
|
||||
port (a `pio device monitor` in `serial_sessions/`, for instance).
|
||||
For serial: raises `ConnectionError` immediately if another serial
|
||||
session holds the port (a `pio device monitor` in `serial_sessions/`).
|
||||
For TCP: no exclusive-access requirement, so the serial-session check
|
||||
is skipped — but the `port_lock` still serializes parallel `connect()`
|
||||
calls to the same daemon endpoint.
|
||||
|
||||
`timeout_s` is plumbed through to both `SerialInterface(timeout=...)`
|
||||
and `TCPInterface(timeout=...)`. The meshtastic library uses the value
|
||||
as the reply-wait deadline for `localNode.waitForConfig()` during
|
||||
construction and for any subsequent admin RPC. `int()`-converted at
|
||||
the boundary because the upstream API expects whole seconds.
|
||||
"""
|
||||
resolved = resolve_port(port)
|
||||
timeout = int(timeout_s)
|
||||
|
||||
if is_tcp_port(resolved):
|
||||
from meshtastic.tcp_interface import (
|
||||
TCPInterface, # type: ignore[import-untyped]
|
||||
)
|
||||
|
||||
host, tcp_port = parse_tcp_port(resolved)
|
||||
lock = registry.port_lock(resolved)
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ConnectionError(
|
||||
f"TCP endpoint {resolved} is busy — another device operation "
|
||||
"is in flight. Retry shortly."
|
||||
)
|
||||
|
||||
iface = None
|
||||
try:
|
||||
iface = TCPInterface(
|
||||
hostname=host,
|
||||
portNumber=tcp_port,
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
yield iface
|
||||
finally:
|
||||
if iface is not None:
|
||||
try:
|
||||
iface.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lock.release()
|
||||
except RuntimeError:
|
||||
pass
|
||||
return
|
||||
|
||||
from meshtastic.serial_interface import (
|
||||
SerialInterface, # type: ignore[import-untyped]
|
||||
)
|
||||
|
||||
resolved = resolve_port(port)
|
||||
|
||||
active = registry.active_session_for_port(resolved)
|
||||
if active is not None:
|
||||
raise ConnectionError(
|
||||
@@ -70,7 +210,12 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
|
||||
iface = None
|
||||
try:
|
||||
iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False)
|
||||
iface = SerialInterface(
|
||||
devPath=resolved,
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
yield iface
|
||||
finally:
|
||||
if iface is not None:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""USB/serial device discovery.
|
||||
"""USB/serial + TCP device discovery.
|
||||
|
||||
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
|
||||
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
|
||||
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
|
||||
a Meshtastic device" signal.
|
||||
|
||||
If `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` is set, a synthetic entry for the
|
||||
`meshtasticd` daemon at that endpoint is prepended to the result, so
|
||||
`resolve_port(None)` auto-selects it like a USB candidate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from serial.tools import list_ports
|
||||
@@ -19,6 +24,45 @@ def _to_hex(value: int | None) -> str | None:
|
||||
return f"0x{value:04x}"
|
||||
|
||||
|
||||
def _tcp_endpoint_from_env() -> dict[str, Any] | None:
|
||||
"""Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set.
|
||||
|
||||
If the env var is malformed (non-integer port, path-like host, etc.),
|
||||
return an entry with `likely_meshtastic=False` and the parser error in
|
||||
the description, rather than raising — `list_devices` is the diagnostic
|
||||
tool a user reaches for when their env var isn't working, so it must
|
||||
not crash on misconfiguration.
|
||||
"""
|
||||
host = os.environ.get("MESHTASTIC_MCP_TCP_HOST")
|
||||
if not host:
|
||||
return None
|
||||
# Lazy import to avoid a circular dependency (connection imports devices).
|
||||
from . import connection
|
||||
|
||||
try:
|
||||
port = connection.normalize_tcp_endpoint(host)
|
||||
description = "meshtasticd (TCP)"
|
||||
likely = True
|
||||
except connection.ConnectionError as e:
|
||||
# Surface the raw env-var value plus the parser's reason so the
|
||||
# user can see exactly what they set and why it was rejected.
|
||||
# Don't double the scheme if the user already prefixed `tcp://`.
|
||||
port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}"
|
||||
description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}"
|
||||
likely = False
|
||||
return {
|
||||
"port": port,
|
||||
"vid": None,
|
||||
"pid": None,
|
||||
"description": description,
|
||||
"manufacturer": None,
|
||||
"product": None,
|
||||
"serial_number": None,
|
||||
"likely_meshtastic": likely,
|
||||
"blacklisted": False,
|
||||
}
|
||||
|
||||
|
||||
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||
"""Return enriched info for serial ports, flagging Meshtastic candidates.
|
||||
|
||||
@@ -70,6 +114,22 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||
}
|
||||
)
|
||||
|
||||
# Stable ordering: likely_meshtastic first, then by port path
|
||||
results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"]))
|
||||
# Append the TCP endpoint (if env var set) and sort everything together.
|
||||
tcp_entry = _tcp_endpoint_from_env()
|
||||
if tcp_entry is not None:
|
||||
results.append(tcp_entry)
|
||||
|
||||
# Stable ordering: likely_meshtastic first; within rank, TCP wins over
|
||||
# USB (explicit env-var configuration takes precedence over USB
|
||||
# enumeration); then by port path. A misconfigured TCP entry has
|
||||
# likely_meshtastic=False and lands among the other ignored entries —
|
||||
# it does NOT pre-empt real USB devices at the top of the list.
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
not r["likely_meshtastic"],
|
||||
not r["port"].startswith("tcp://"),
|
||||
r["port"],
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any
|
||||
|
||||
import serial
|
||||
|
||||
from . import boards, config, devices, pio, userprefs
|
||||
from . import boards, config, connection, devices, pio, userprefs
|
||||
|
||||
# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
|
||||
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
|
||||
@@ -46,6 +46,18 @@ def _require_confirm(confirm: bool, operation: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _reject_native_env(env: str, operation: str) -> None:
|
||||
"""`native*` envs build a host executable, not firmware — there's no
|
||||
upload step. The user wants `build` (or just runs the binary directly).
|
||||
"""
|
||||
if env.startswith("native"):
|
||||
raise FlashError(
|
||||
f"{operation} is not applicable for env {env!r}: native envs "
|
||||
"produce a host executable, not flashable firmware. Use `build` "
|
||||
"instead, then run the resulting binary directly."
|
||||
)
|
||||
|
||||
|
||||
def _artifacts_for(env: str) -> list[Path]:
|
||||
build_dir = config.firmware_root() / ".pio" / "build" / env
|
||||
if not build_dir.is_dir():
|
||||
@@ -141,6 +153,8 @@ def flash(
|
||||
that pio performs will pick up the injected values.
|
||||
"""
|
||||
_require_confirm(confirm, "flash")
|
||||
_reject_native_env(env, "flash")
|
||||
connection.reject_if_tcp(port, "flash")
|
||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||
result = pio.run(
|
||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||
@@ -200,6 +214,7 @@ def erase_and_flash(
|
||||
in that case) since a cached factory.bin would not reflect the new prefs.
|
||||
"""
|
||||
_require_confirm(confirm, "erase_and_flash")
|
||||
connection.reject_if_tcp(port, "erase_and_flash")
|
||||
_check_esp32_env(env)
|
||||
|
||||
if userprefs_overrides and skip_build:
|
||||
@@ -257,6 +272,7 @@ def update_flash(
|
||||
overrides are provided we always force a rebuild.
|
||||
"""
|
||||
_require_confirm(confirm, "update_flash")
|
||||
connection.reject_if_tcp(port, "update_flash")
|
||||
_check_esp32_env(env)
|
||||
|
||||
if userprefs_overrides and skip_build:
|
||||
@@ -391,6 +407,7 @@ def touch_1200bps(
|
||||
|
||||
Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`.
|
||||
"""
|
||||
connection.reject_if_tcp(port, "touch_1200bps")
|
||||
before_list = devices.list_devices(include_unknown=True)
|
||||
before_ports = {d["port"] for d in before_list}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from . import config, pio
|
||||
from . import config, connection, pio
|
||||
|
||||
_TIMEOUT_SHORT = 30
|
||||
_TIMEOUT_LONG = 600
|
||||
@@ -102,6 +102,7 @@ def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||
connection.reject_if_tcp(port, "esptool_chip_info")
|
||||
binary = config.esptool_bin()
|
||||
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
|
||||
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
|
||||
@@ -116,6 +117,7 @@ def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
|
||||
"""Full-chip erase. Leaves the device unbootable until reflashed."""
|
||||
_require_confirm(confirm, "esptool_erase_flash")
|
||||
connection.reject_if_tcp(port, "esptool_erase_flash")
|
||||
binary = config.esptool_bin()
|
||||
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
|
||||
# first; if it fails with unknown command, retry old.
|
||||
@@ -134,6 +136,7 @@ def esptool_raw(
|
||||
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
|
||||
if not args:
|
||||
raise ToolError("args must not be empty")
|
||||
connection.reject_if_tcp(port, "esptool_raw")
|
||||
# Find the first non-flag arg (the subcommand).
|
||||
subcommand = next((a for a in args if not a.startswith("-")), None)
|
||||
if subcommand and subcommand.replace("-", "_") in {
|
||||
@@ -156,6 +159,7 @@ NRFUTIL_DESTRUCTIVE = {"dfu", "settings"}
|
||||
|
||||
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||
_require_confirm(confirm, "nrfutil_dfu")
|
||||
connection.reject_if_tcp(port, "nrfutil_dfu")
|
||||
pkg = Path(package_path).expanduser()
|
||||
if not pkg.is_file():
|
||||
raise ToolError(f"Package not found: {pkg}")
|
||||
@@ -213,6 +217,7 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]:
|
||||
def picotool_info(port: str | None = None) -> dict[str, Any]:
|
||||
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
|
||||
only — picotool auto-detects."""
|
||||
connection.reject_if_tcp(port, "picotool_info")
|
||||
binary = config.picotool_bin()
|
||||
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
||||
if res["exit_code"] != 0:
|
||||
|
||||
@@ -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"]
|
||||
@@ -71,6 +71,10 @@ def open_session(
|
||||
If `env` is supplied, pio resolves baud and filters from platformio.ini.
|
||||
Otherwise uses the supplied `baud` and `filters` (default `['direct']`).
|
||||
"""
|
||||
# Lazy import to avoid circular: registry imports serial_session.
|
||||
from . import connection
|
||||
|
||||
connection.reject_if_tcp(port, "serial_open")
|
||||
args = ["device", "monitor", "--port", port, "--no-reconnect"]
|
||||
effective_filters: list[str]
|
||||
effective_baud: int = baud
|
||||
|
||||
@@ -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,383 @@
|
||||
"""TCP transport plumbing in connection.py + devices.py.
|
||||
|
||||
Pure-Python tests — no real device or daemon required. Mocks `TCPInterface`
|
||||
when exercising `connect()`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import connection, devices
|
||||
|
||||
# ---------- helpers --------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsTcpPort:
|
||||
def test_tcp_scheme(self) -> None:
|
||||
assert connection.is_tcp_port("tcp://localhost") is True
|
||||
assert connection.is_tcp_port("tcp://localhost:4403") is True
|
||||
assert connection.is_tcp_port("tcp://192.168.1.50:9999") is True
|
||||
|
||||
def test_serial_paths(self) -> None:
|
||||
assert connection.is_tcp_port("/dev/cu.usbmodem1234") is False
|
||||
assert connection.is_tcp_port("/dev/ttyUSB0") is False
|
||||
assert connection.is_tcp_port("COM3") is False
|
||||
|
||||
def test_empty_or_none(self) -> None:
|
||||
assert connection.is_tcp_port(None) is False
|
||||
assert connection.is_tcp_port("") is False
|
||||
|
||||
|
||||
class TestParseTcpPort:
|
||||
def test_default_port(self) -> None:
|
||||
assert connection.parse_tcp_port("tcp://localhost") == ("localhost", 4403)
|
||||
|
||||
def test_explicit_port(self) -> None:
|
||||
assert connection.parse_tcp_port("tcp://localhost:9999") == (
|
||||
"localhost",
|
||||
9999,
|
||||
)
|
||||
|
||||
def test_ip_with_port(self) -> None:
|
||||
assert connection.parse_tcp_port("tcp://192.168.1.50:4403") == (
|
||||
"192.168.1.50",
|
||||
4403,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeTcpEndpoint:
|
||||
def test_bare_host(self) -> None:
|
||||
assert connection.normalize_tcp_endpoint("localhost") == "tcp://localhost:4403"
|
||||
|
||||
def test_host_port(self) -> None:
|
||||
assert (
|
||||
connection.normalize_tcp_endpoint("localhost:5000")
|
||||
== "tcp://localhost:5000"
|
||||
)
|
||||
|
||||
def test_full_url(self) -> None:
|
||||
assert (
|
||||
connection.normalize_tcp_endpoint("tcp://1.2.3.4") == "tcp://1.2.3.4:4403"
|
||||
)
|
||||
assert (
|
||||
connection.normalize_tcp_endpoint("tcp://1.2.3.4:9999")
|
||||
== "tcp://1.2.3.4:9999"
|
||||
)
|
||||
|
||||
def test_idempotent(self) -> None:
|
||||
once = connection.normalize_tcp_endpoint("localhost:4403")
|
||||
twice = connection.normalize_tcp_endpoint(once)
|
||||
assert once == twice == "tcp://localhost:4403"
|
||||
|
||||
def test_path_like_endpoint_rejected(self) -> None:
|
||||
# Serial port paths and Windows drive paths are common config typos
|
||||
# (someone passes a serial path to MESHTASTIC_MCP_TCP_HOST). Reject
|
||||
# rather than producing a nonsense `tcp:///dev/cu.foo:4403` URL.
|
||||
with pytest.raises(connection.ConnectionError, match="path separator"):
|
||||
connection.normalize_tcp_endpoint("/dev/cu.foo")
|
||||
with pytest.raises(connection.ConnectionError):
|
||||
connection.normalize_tcp_endpoint("tcp:///dev/cu.foo:4403")
|
||||
with pytest.raises(connection.ConnectionError):
|
||||
connection.normalize_tcp_endpoint(r"C:\Windows\System32")
|
||||
|
||||
def test_non_integer_port_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="not an integer"):
|
||||
connection.normalize_tcp_endpoint("tcp://host:notaport")
|
||||
with pytest.raises(connection.ConnectionError, match="not an integer"):
|
||||
connection.normalize_tcp_endpoint("host:notaport")
|
||||
|
||||
def test_empty_host_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="empty host"):
|
||||
connection.normalize_tcp_endpoint("tcp://:4403")
|
||||
|
||||
def test_port_out_of_range_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.normalize_tcp_endpoint("tcp://host:0")
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.normalize_tcp_endpoint("tcp://host:65536")
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.normalize_tcp_endpoint("host:99999")
|
||||
|
||||
|
||||
class TestParseTcpPortValidation:
|
||||
def test_missing_scheme_rejected(self) -> None:
|
||||
# parse_tcp_port is a low-level helper that requires the scheme.
|
||||
# Misuse should fail loudly rather than silently mis-parsing.
|
||||
with pytest.raises(connection.ConnectionError, match="expected"):
|
||||
connection.parse_tcp_port("localhost:4403")
|
||||
|
||||
def test_negative_port_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.parse_tcp_port("tcp://host:-1")
|
||||
|
||||
|
||||
# ---------- reject_if_tcp --------------------------------------------------
|
||||
|
||||
|
||||
class TestRejectIfTcp:
|
||||
def test_rejects_tcp(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="not applicable"):
|
||||
connection.reject_if_tcp("tcp://localhost", "esptool_chip_info")
|
||||
|
||||
def test_passes_through_serial(self) -> None:
|
||||
connection.reject_if_tcp("/dev/cu.usbmodem1", "esptool_chip_info") # no raise
|
||||
|
||||
def test_passes_through_none(self) -> None:
|
||||
# None means "auto-detect"; not the explicit-arg case we guard.
|
||||
connection.reject_if_tcp(None, "esptool_chip_info") # no raise
|
||||
|
||||
|
||||
# ---------- resolve_port ---------------------------------------------------
|
||||
|
||||
|
||||
class TestResolvePort:
|
||||
def test_explicit_serial_passthrough(self) -> None:
|
||||
assert connection.resolve_port("/dev/cu.usbmodem999") == "/dev/cu.usbmodem999"
|
||||
|
||||
def test_explicit_tcp_normalized(self) -> None:
|
||||
assert connection.resolve_port("tcp://localhost") == "tcp://localhost:4403"
|
||||
|
||||
def test_no_port_no_devices_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
with patch.object(devices, "list_devices", return_value=[]):
|
||||
with pytest.raises(
|
||||
connection.ConnectionError, match="No Meshtastic devices"
|
||||
):
|
||||
connection.resolve_port(None)
|
||||
|
||||
def test_no_port_one_candidate_selected(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
fake = [{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}]
|
||||
with patch.object(devices, "list_devices", return_value=fake):
|
||||
assert connection.resolve_port(None) == "/dev/cu.usbmodem1"
|
||||
|
||||
def test_no_port_multiple_candidates_errors(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
fake = [
|
||||
{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True},
|
||||
{"port": "/dev/cu.usbmodem2", "likely_meshtastic": True},
|
||||
]
|
||||
with patch.object(devices, "list_devices", return_value=fake):
|
||||
with pytest.raises(connection.ConnectionError, match="Multiple"):
|
||||
connection.resolve_port(None)
|
||||
|
||||
def test_env_var_surfaces_tcp_via_devices(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
||||
# Don't patch list_devices — let the real env-var path run, but stub
|
||||
# the USB enumeration to keep the test hermetic.
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
assert connection.resolve_port(None) == "tcp://localhost:4403"
|
||||
|
||||
|
||||
# ---------- devices.list_devices TCP entry --------------------------------
|
||||
|
||||
|
||||
class TestDevicesTcpEntry:
|
||||
def test_no_env_var_no_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices()
|
||||
assert all(not d["port"].startswith("tcp://") for d in ds)
|
||||
|
||||
def test_env_var_adds_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "myhost:9999")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices()
|
||||
tcp = [d for d in ds if d["port"].startswith("tcp://")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["port"] == "tcp://myhost:9999"
|
||||
assert tcp[0]["likely_meshtastic"] is True
|
||||
assert tcp[0]["description"] == "meshtasticd (TCP)"
|
||||
|
||||
def test_tcp_entry_first_in_results(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices()
|
||||
assert ds, "expected at least the TCP entry"
|
||||
assert ds[0]["port"].startswith("tcp://")
|
||||
|
||||
def test_invalid_env_var_does_not_break_list_devices(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `list_devices` is the diagnostic tool reached for when an env var
|
||||
# isn't working — it must not throw on misconfiguration.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["likely_meshtastic"] is False
|
||||
assert "invalid MESHTASTIC_MCP_TCP_HOST" in tcp[0]["description"]
|
||||
assert "not an integer" in tcp[0]["description"]
|
||||
|
||||
def test_invalid_env_var_excluded_from_resolve_port_autodetect(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `likely_meshtastic=False` keeps the bad TCP entry out of the
|
||||
# auto-select path — `resolve_port(None)` should still report
|
||||
# "no Meshtastic devices" rather than picking a broken endpoint.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
with pytest.raises(connection.ConnectionError, match="No Meshtastic"):
|
||||
connection.resolve_port(None)
|
||||
|
||||
def test_invalid_env_var_does_not_double_tcp_scheme(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# If a user mistakenly sets `MESHTASTIC_MCP_TCP_HOST=tcp://host:bad`,
|
||||
# the diagnostic entry must surface the raw value as-is rather than
|
||||
# producing `tcp://tcp://host:bad`.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "tcp://host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["port"] == "tcp://host:notaport"
|
||||
assert "tcp://tcp://" not in tcp[0]["port"]
|
||||
|
||||
def test_invalid_env_var_does_not_pre_empt_real_usb_devices(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Sort ordering: a misconfigured TCP env var must NOT take position 0
|
||||
# ahead of real USB candidates. Position 0 is reserved for the highest
|
||||
# rank (likely_meshtastic=True), with TCP-before-USB as a tiebreaker
|
||||
# within rank.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
|
||||
# Stub a USB Meshtastic candidate (Espressif VID, port present in
|
||||
# findPorts).
|
||||
class FakeInfo:
|
||||
def __init__(self, device: str, vid: int, pid: int) -> None:
|
||||
self.device = device
|
||||
self.vid = vid
|
||||
self.pid = pid
|
||||
self.description = "Heltec V3"
|
||||
self.manufacturer = "Espressif"
|
||||
self.product = "USB JTAG/serial"
|
||||
self.serial_number = "abc"
|
||||
|
||||
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
|
||||
with patch(
|
||||
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
|
||||
), patch(
|
||||
"meshtastic.util.findPorts",
|
||||
return_value=["/dev/cu.usbmodem4201"],
|
||||
):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
|
||||
assert ds, "expected at least the USB + TCP entries"
|
||||
# Real USB candidate must be at position 0 — it's likely_meshtastic.
|
||||
assert ds[0]["port"] == "/dev/cu.usbmodem4201"
|
||||
assert ds[0]["likely_meshtastic"] is True
|
||||
# The malformed TCP entry exists but lands among the unlikely entries.
|
||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["likely_meshtastic"] is False
|
||||
assert ds.index(tcp[0]) > 0
|
||||
|
||||
def test_likely_tcp_entry_wins_tiebreak_over_usb(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Conversely, a *valid* TCP env var should sort ahead of USB
|
||||
# candidates of equal likely_meshtastic rank — explicit env-var
|
||||
# configuration is a precedence signal.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403")
|
||||
|
||||
class FakeInfo:
|
||||
def __init__(self, device: str, vid: int, pid: int) -> None:
|
||||
self.device = device
|
||||
self.vid = vid
|
||||
self.pid = pid
|
||||
self.description = "Heltec V3"
|
||||
self.manufacturer = "Espressif"
|
||||
self.product = "USB JTAG/serial"
|
||||
self.serial_number = "abc"
|
||||
|
||||
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
|
||||
with patch(
|
||||
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
|
||||
), patch(
|
||||
"meshtastic.util.findPorts",
|
||||
return_value=["/dev/cu.usbmodem4201"],
|
||||
):
|
||||
ds = devices.list_devices()
|
||||
|
||||
assert ds[0]["port"] == "tcp://localhost:4403"
|
||||
assert ds[0]["likely_meshtastic"] is True
|
||||
|
||||
|
||||
# ---------- connect() routing ---------------------------------------------
|
||||
|
||||
|
||||
class TestConnectRoutesTcp:
|
||||
def test_connect_uses_tcp_interface_for_tcp_port(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Verify the TCP branch instantiates `TCPInterface(hostname, portNumber)`
|
||||
and never touches `SerialInterface`."""
|
||||
# Make sure the env var doesn't leak in and confuse resolve_port.
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp, patch(
|
||||
"meshtastic.serial_interface.SerialInterface"
|
||||
) as mock_serial:
|
||||
mock_tcp.return_value.close.return_value = None
|
||||
with connection.connect(port="tcp://example.com:1234", timeout_s=12.0):
|
||||
pass
|
||||
|
||||
mock_tcp.assert_called_once_with(
|
||||
hostname="example.com",
|
||||
portNumber=1234,
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=12,
|
||||
)
|
||||
mock_serial.assert_not_called()
|
||||
|
||||
def test_connect_plumbs_timeout_to_serial_interface(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Verify the serial branch also propagates `timeout_s` so callers
|
||||
passing a custom timeout to `device_info` / `list_nodes` / etc. don't
|
||||
silently get the library default."""
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
|
||||
with patch("meshtastic.serial_interface.SerialInterface") as mock_serial, patch(
|
||||
"meshtastic.tcp_interface.TCPInterface"
|
||||
) as mock_tcp:
|
||||
mock_serial.return_value.close.return_value = None
|
||||
with connection.connect(port="/dev/cu.fake", timeout_s=20.0):
|
||||
pass
|
||||
|
||||
mock_serial.assert_called_once_with(
|
||||
devPath="/dev/cu.fake",
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=20,
|
||||
)
|
||||
mock_tcp.assert_not_called()
|
||||
|
||||
def test_connect_releases_lock_on_tcp_failure(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
||||
mock_tcp.side_effect = RuntimeError("boom")
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
with connection.connect(port="tcp://locktest:4403"):
|
||||
pass
|
||||
|
||||
# Lock should be released — a second connect attempt must not fail
|
||||
# with "busy".
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
||||
mock_tcp.return_value.close.return_value = None
|
||||
with connection.connect(port="tcp://locktest:4403"):
|
||||
pass
|
||||
@@ -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"
|
||||
+8
-7
@@ -29,6 +29,7 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DUSE_THREAD_NAMES
|
||||
-DTINYGPS_OPTION_NO_CUSTOM_FIELDS
|
||||
-DPB_ENABLE_MALLOC=1
|
||||
-DPB_VALIDATE_UTF8=1
|
||||
-DRADIOLIB_EXCLUDE_CC1101=1
|
||||
-DRADIOLIB_EXCLUDE_NRF24=1
|
||||
-DRADIOLIB_EXCLUDE_RF69=1
|
||||
@@ -56,9 +57,7 @@ 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
|
||||
@@ -68,7 +67,7 @@ monitor_speed = 115200
|
||||
monitor_filters = direct
|
||||
lib_deps =
|
||||
# renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master
|
||||
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/21e484f409cde18d44012caef84c244eb5ca28f3.zip
|
||||
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip
|
||||
# renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master
|
||||
https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip
|
||||
# renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master
|
||||
@@ -121,12 +120,12 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
||||
|
||||
[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/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip
|
||||
https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip
|
||||
|
||||
; Common libs for environmental measurements in telemetry module
|
||||
[environmental_base]
|
||||
@@ -171,8 +170,8 @@ lib_deps =
|
||||
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
||||
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
|
||||
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
|
||||
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
|
||||
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
|
||||
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
||||
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
||||
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
||||
@@ -227,6 +226,8 @@ 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: 4d5b500df5...1d6f1a71ff
+15
-18
@@ -26,6 +26,8 @@ SOFTWARE.*/
|
||||
|
||||
#include "DebugConfiguration.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#endif
|
||||
@@ -119,27 +121,22 @@ bool Syslog::vlogf(uint16_t pri, const char *fmt, va_list args)
|
||||
|
||||
bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args)
|
||||
{
|
||||
char *message;
|
||||
size_t initialLen;
|
||||
size_t len;
|
||||
bool result;
|
||||
// First measure the formatted length using a copy of args; passing args directly
|
||||
// to vsnprintf consumes it, and reusing a consumed va_list is undefined behavior.
|
||||
va_list args_measure;
|
||||
va_copy(args_measure, args);
|
||||
int needed = vsnprintf(nullptr, 0, fmt, args_measure);
|
||||
va_end(args_measure);
|
||||
|
||||
initialLen = strlen(fmt);
|
||||
if (needed < 0)
|
||||
return false; // encoding error
|
||||
|
||||
message = new char[initialLen + 1];
|
||||
auto message = std::unique_ptr<char[]>(new char[static_cast<size_t>(needed) + 1]);
|
||||
int written = vsnprintf(message.get(), static_cast<size_t>(needed) + 1, fmt, args);
|
||||
if (written < 0)
|
||||
return false;
|
||||
|
||||
len = vsnprintf(message, initialLen + 1, fmt, args);
|
||||
if (len > initialLen) {
|
||||
delete[] message;
|
||||
message = new char[len + 1];
|
||||
|
||||
vsnprintf(message, len + 1, fmt, args);
|
||||
}
|
||||
|
||||
result = this->_sendLog(pri, appName, message);
|
||||
|
||||
delete[] message;
|
||||
return result;
|
||||
return this->_sendLog(pri, appName, message.get());
|
||||
}
|
||||
|
||||
inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message)
|
||||
|
||||
@@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
||||
bool usePreset)
|
||||
{
|
||||
|
||||
// If use_preset is false, always return "Custom"
|
||||
// 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 (!usePreset) {
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
+64
-94
@@ -79,28 +79,46 @@ bool copyFile(const char *from, const char *to)
|
||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// take SPI Lock
|
||||
spiLock->lock();
|
||||
// rename was fixed for ESP32 IDF LittleFS in April
|
||||
bool result = FSCom.rename(pathFrom, pathTo);
|
||||
spiLock->unlock();
|
||||
return result;
|
||||
#else
|
||||
// copyFile does its own locking.
|
||||
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Platform-agnostic filesystem format / wipe.
|
||||
*
|
||||
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
|
||||
* native FSCom.format() which erases and reinitialises the LittleFS
|
||||
* partition.
|
||||
*
|
||||
* On Portduino the fs::FS backend has no format() method. We instead
|
||||
* delete /prefs (the only meshtastic data directory written at runtime)
|
||||
* and return. rmDir("/prefs") is already called unconditionally by
|
||||
* factoryReset() so this is a proven primitive on Portduino.
|
||||
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
|
||||
*
|
||||
* @return true on success, false on failure or if no filesystem is configured.
|
||||
*/
|
||||
bool fsFormat()
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
rmDir("/prefs");
|
||||
return FSBegin();
|
||||
#else
|
||||
return FSCom.format();
|
||||
#endif
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the list of files in a directory.
|
||||
*
|
||||
@@ -123,23 +141,21 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (file) {
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
|
||||
#else
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
|
||||
#endif
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
#ifdef ARCH_ESP32
|
||||
strcpy(fileInfo.file_name, file.path());
|
||||
#else
|
||||
strcpy(fileInfo.file_name, file.name());
|
||||
#endif
|
||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
@@ -163,98 +179,59 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
char buffer[255];
|
||||
#endif
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root) {
|
||||
if (!root || !root.isDirectory())
|
||||
return;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (
|
||||
file &&
|
||||
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
listDir(file.path(), levels - 1, del);
|
||||
listDir(filepath, levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Remove %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(file.name(), levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (directory)", file.name());
|
||||
listDir(file.name(), levels - 1, del);
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Delete %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.path());
|
||||
strncpy(buffer, root.path(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.name());
|
||||
strncpy(buffer, root.name(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
const char *rootpath = root.path();
|
||||
#else
|
||||
root.close();
|
||||
const char *rootpath = root.name();
|
||||
#endif
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", rootpath);
|
||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -268,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
void rmDir(const char *dirname)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(dirname, 10, true);
|
||||
#elif defined(ARCH_NRF52)
|
||||
// nRF52 implementation of LittleFS has a recursive delete function
|
||||
FSCom.rmdir_r(dirname);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ void fsInit();
|
||||
void fsListFiles();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||
void rmDir(const char *dirname);
|
||||
|
||||
+182
-87
@@ -40,6 +40,22 @@
|
||||
#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"
|
||||
@@ -78,16 +94,31 @@ static const adc_atten_t atten = ADC_ATTENUATION;
|
||||
#endif
|
||||
#endif // BATTERY_PIN && ARCH_ESP32
|
||||
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#ifndef EXT_PWR_DETECT_MODE
|
||||
#define EXT_PWR_DETECT_MODE INPUT
|
||||
// If using internal pull resistors, we can infer EXT_PWR_DETECT_VALUE
|
||||
#elif EXT_PWR_DETECT_MODE == INPUT_PULLUP
|
||||
#define EXT_PWR_DETECT_VALUE LOW
|
||||
#elif EXT_PWR_DETECT_MODE == INPUT_PULLDOWN
|
||||
#define EXT_PWR_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#ifndef EXT_PWR_DETECT_VALUE
|
||||
#define EXT_PWR_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
#ifndef EXT_CHRG_DETECT_MODE
|
||||
static const uint8_t ext_chrg_detect_mode = INPUT;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_mode = EXT_CHRG_DETECT_MODE;
|
||||
#define EXT_CHRG_DETECT_MODE INPUT
|
||||
// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE
|
||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP
|
||||
#define EXT_CHRG_DETECT_VALUE LOW
|
||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN
|
||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#ifndef EXT_CHRG_DETECT_VALUE
|
||||
static const uint8_t ext_chrg_detect_value = HIGH;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_value = EXT_CHRG_DETECT_VALUE;
|
||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -328,11 +359,17 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_ESP32 // ADC block for espressif platforms
|
||||
#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
|
||||
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
|
||||
@@ -447,28 +484,14 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; }
|
||||
#endif
|
||||
|
||||
/// If we see a battery voltage higher than physics allows - assume charger is
|
||||
/// pumping in power On some boards we don't have the power management chip
|
||||
/// (like AXPxxxx) so we use EXT_PWR_DETECT GPIO pin to detect external power
|
||||
/// source
|
||||
// Detect if an external power source is connected if we don’t have a PMIC;
|
||||
// Firstly prefer EXT_PWR_DETECT GPIO if available,
|
||||
// secondly try an nRF52-specific routine on some variants,
|
||||
// lastly provide a fallback to indicate external power when fully charged.
|
||||
virtual bool isVbusIn() override
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||
// if external powered that pin will be pulled down
|
||||
if (digitalRead(EXT_PWR_DETECT) == LOW) {
|
||||
return true;
|
||||
}
|
||||
// if it's not LOW - check the battery
|
||||
#else
|
||||
// if external powered that pin will be pulled up
|
||||
if (digitalRead(EXT_PWR_DETECT) == HIGH) {
|
||||
return true;
|
||||
}
|
||||
// if it's not HIGH - check the battery
|
||||
#endif
|
||||
// If we have an EXT_PWR_DETECT pin and it indicates no external power, believe it.
|
||||
return false;
|
||||
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
|
||||
|
||||
// technically speaking this should work for all(?) NRF52 boards
|
||||
// but needs testing across multiple devices. NRF52 USB would not even work if
|
||||
@@ -489,9 +512,9 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
}
|
||||
#endif
|
||||
#if defined(ELECROW_ThinkNode_M6)
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value || isVbusIn();
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn();
|
||||
#elif EXT_CHRG_DETECT
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
#elif defined(BATTERY_CHARGING_INV)
|
||||
return !digitalRead(BATTERY_CHARGING_INV);
|
||||
#else
|
||||
@@ -530,6 +553,11 @@ 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)
|
||||
|
||||
@@ -619,14 +647,10 @@ Power::Power() : OSThread("Power")
|
||||
bool Power::analogInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||
pinMode(EXT_PWR_DETECT, INPUT_PULLUP);
|
||||
#else
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
#endif
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
||||
#endif
|
||||
|
||||
#ifdef BATTERY_PIN
|
||||
@@ -639,7 +663,9 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_ESP32 // ESP32 needs special analog stuff
|
||||
#ifdef ARCH_STM32WL
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#elif defined(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;
|
||||
@@ -649,7 +675,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
|
||||
@@ -679,7 +705,7 @@ bool Power::analogInit()
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#ifndef ARCH_ESP32
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
@@ -717,37 +743,17 @@ bool Power::setup()
|
||||
found = true;
|
||||
#endif
|
||||
}
|
||||
#ifdef EXT_PWR_DETECT
|
||||
attachInterrupt(
|
||||
EXT_PWR_DETECT,
|
||||
[]() {
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
},
|
||||
CHANGE);
|
||||
#endif
|
||||
#ifdef BATTERY_CHARGING_INV
|
||||
attachInterrupt(
|
||||
BATTERY_CHARGING_INV,
|
||||
[]() {
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
},
|
||||
CHANGE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
attachInterrupt(
|
||||
EXT_CHRG_DETECT,
|
||||
[]() {
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
BaseType_t higherWake = 0;
|
||||
},
|
||||
CHANGE);
|
||||
#endif
|
||||
attachPowerInterrupts();
|
||||
enabled = found;
|
||||
low_voltage_counter = 0;
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Register callbacks for before and after lightsleep
|
||||
// Used to detach and reattach interrupts
|
||||
lsObserver.observe(¬ifyLightSleep);
|
||||
lsEndObserver.observe(¬ifyLightSleepEnd);
|
||||
#endif
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -775,8 +781,10 @@ void Power::reboot()
|
||||
rp2040.reboot();
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
deInitApiServer();
|
||||
#ifdef __linux__
|
||||
if (aLinuxInputImpl)
|
||||
aLinuxInputImpl->deInit();
|
||||
#endif
|
||||
SPI.end();
|
||||
Wire.end();
|
||||
Serial1.end();
|
||||
@@ -994,6 +1002,14 @@ int32_t Power::runOnce()
|
||||
powerFSM.trigger(EVENT_POWER_CONNECTED);
|
||||
}
|
||||
|
||||
#ifdef PMU_POWER_BUTTON_IS_CANCEL
|
||||
// cancel action also turns the screen on and off.
|
||||
if (PMU->isPekeyShortPressIrq()) {
|
||||
LOG_INFO("Input: Corona Button Click");
|
||||
InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0};
|
||||
inputBroker->injectInputEvent(&event);
|
||||
}
|
||||
#endif
|
||||
/*
|
||||
Other things we could check if we cared...
|
||||
|
||||
@@ -1010,13 +1026,6 @@ int32_t Power::runOnce()
|
||||
LOG_DEBUG("Battery removed");
|
||||
}
|
||||
*/
|
||||
#ifndef T_WATCH_S3 // FIXME - why is this triggering on the T-Watch S3?
|
||||
if (PMU->isPekeyLongPressIrq()) {
|
||||
LOG_DEBUG("PEK long button press");
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
PMU->clearIrqStatus();
|
||||
}
|
||||
@@ -1026,6 +1035,97 @@ int32_t Power::runOnce()
|
||||
return (statusHandler && statusHandler->isInitialized()) ? (1000 * 20) : RUN_SAME;
|
||||
}
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
|
||||
// Detach our class' interrupts before lightsleep
|
||||
// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press
|
||||
int Power::beforeLightSleep(void *unused)
|
||||
{
|
||||
LOG_WARN("Detaching power interrupts for sleep");
|
||||
detachPowerInterrupts();
|
||||
return 0; // Indicates success
|
||||
}
|
||||
|
||||
// Reconfigure our interrupts
|
||||
// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep
|
||||
int Power::afterLightSleep(esp_sleep_wakeup_cause_t cause)
|
||||
{
|
||||
attachPowerInterrupts();
|
||||
return 0; // Indicates success
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Attach (or re-attach) hardware interrupts for power management
|
||||
* Public method. Used outside class when waking from MCU sleep
|
||||
*/
|
||||
void Power::attachPowerInterrupts()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
attachInterrupt(
|
||||
EXT_PWR_DETECT,
|
||||
[]() {
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
},
|
||||
CHANGE);
|
||||
#endif
|
||||
#ifdef BATTERY_CHARGING_INV
|
||||
attachInterrupt(
|
||||
BATTERY_CHARGING_INV,
|
||||
[]() {
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
},
|
||||
CHANGE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
attachInterrupt(
|
||||
EXT_CHRG_DETECT,
|
||||
[]() {
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
BaseType_t higherWake = 0;
|
||||
},
|
||||
CHANGE);
|
||||
#endif
|
||||
#ifdef PMU_IRQ
|
||||
if (PMU) {
|
||||
attachInterrupt(
|
||||
PMU_IRQ,
|
||||
[]() {
|
||||
pmu_irq = true;
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
},
|
||||
FALLING);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/*
|
||||
* Detach the "normal" button interrupts.
|
||||
* Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep
|
||||
*/
|
||||
void Power::detachPowerInterrupts()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
detachInterrupt(EXT_PWR_DETECT);
|
||||
#endif
|
||||
#ifdef BATTERY_CHARGING_INV
|
||||
detachInterrupt(BATTERY_CHARGING_INV);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
detachInterrupt(EXT_CHRG_DETECT);
|
||||
#endif
|
||||
#ifdef PMU_IRQ
|
||||
if (PMU) {
|
||||
detachInterrupt(PMU_IRQ);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* Init the power manager chip
|
||||
*
|
||||
@@ -1297,21 +1397,16 @@ bool Power::axpChipInit()
|
||||
uint64_t pmuIrqMask = 0;
|
||||
|
||||
if (PMU->getChipModel() == XPOWERS_AXP192) {
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_BAT_INSERT_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_VBUS_REMOVE_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
} else if (PMU->getChipModel() == XPOWERS_AXP2101) {
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_BAT_INSERT_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_VBUS_REMOVE_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
}
|
||||
|
||||
pinMode(PMU_IRQ, INPUT);
|
||||
attachInterrupt(
|
||||
PMU_IRQ, [] { pmu_irq = true; }, FALLING);
|
||||
|
||||
// we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ
|
||||
// because it occurs repeatedly while there is no battery also it could cause
|
||||
// inadvertent waking from light sleep just because the battery filled we
|
||||
// don't look for AXPXXX_BATT_REMOVED_IRQ because it occurs repeatedly while
|
||||
// no battery installed we don't look at AXPXXX_VBUS_REMOVED_IRQ because we
|
||||
// don't have anything hooked to vbus
|
||||
// We wake on IRQ, so only enable the IRQs that we care about.
|
||||
// we want USB plug and unplug to update the screen and LED status,
|
||||
// and short press on the power button to trigger the "cancel" action in the UI (which also turns the screen on and off).
|
||||
PMU->enableIRQ(pmuIrqMask);
|
||||
|
||||
PMU->clearIrqStatus();
|
||||
@@ -1777,7 +1872,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
||||
{
|
||||
#if defined(EXT_CHRG_DETECT)
|
||||
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
|
||||
#endif
|
||||
return false;
|
||||
@@ -1786,7 +1881,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
||||
virtual bool isCharging() override
|
||||
{
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
|
||||
#endif
|
||||
// by default, we check the battery voltage only
|
||||
@@ -1808,10 +1903,10 @@ SerialBatteryLevel serialBatteryLevel;
|
||||
bool Power::serialBatteryInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
||||
#endif
|
||||
|
||||
bool result = serialBatteryLevel.runOnce();
|
||||
|
||||
+48
-11
@@ -58,6 +58,35 @@ static bool isPowered()
|
||||
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
|
||||
}
|
||||
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
static void t5BacklightOffForSleep()
|
||||
{
|
||||
t5BacklightSetForcedBySleep(true);
|
||||
}
|
||||
|
||||
static void t5BacklightWakeFromSleep()
|
||||
{
|
||||
t5BacklightSetForcedBySleep(false);
|
||||
}
|
||||
|
||||
static void t5BacklightOffForTimeout()
|
||||
{
|
||||
t5BacklightSetForcedByTimeout(true);
|
||||
t5TouchSetForcedByTimeout(true);
|
||||
}
|
||||
|
||||
static void t5BacklightOnFromUserInput()
|
||||
{
|
||||
t5BacklightHandleUserInput();
|
||||
t5TouchHandleUserInput();
|
||||
}
|
||||
#else
|
||||
static void t5BacklightOffForSleep() {}
|
||||
static void t5BacklightWakeFromSleep() {}
|
||||
static void t5BacklightOffForTimeout() {}
|
||||
static void t5BacklightOnFromUserInput() {}
|
||||
#endif
|
||||
|
||||
static void sdsEnter()
|
||||
{
|
||||
LOG_POWERFSM("State: SDS");
|
||||
@@ -87,6 +116,7 @@ static void lsEnter()
|
||||
LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs);
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
t5BacklightOffForSleep();
|
||||
secsSlept = 0; // How long have we been sleeping this time
|
||||
|
||||
// LOG_INFO("lsEnter end");
|
||||
@@ -159,6 +189,8 @@ static void lsIdle()
|
||||
static void lsExit()
|
||||
{
|
||||
LOG_POWERFSM("State: lsExit");
|
||||
// Lift the light-sleep force-off gate when leaving LS.
|
||||
t5BacklightWakeFromSleep();
|
||||
}
|
||||
|
||||
static void nbEnter()
|
||||
@@ -180,6 +212,8 @@ static void darkEnter()
|
||||
setBluetoothEnable(true);
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
// Screen timeout enters DARK; ensure backlight also turns off.
|
||||
t5BacklightOffForTimeout();
|
||||
}
|
||||
|
||||
static void serialEnter()
|
||||
@@ -289,12 +323,13 @@ void PowerFSM_setup()
|
||||
powerFSM.add_transition(&stateNB, &stateNB, EVENT_PACKET_FOR_PHONE, NULL, "Received packet, resetting win wake");
|
||||
|
||||
// Handle press events - note: we ignore button presses when in API mode
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL,
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput,
|
||||
"Press"); // reenter On to restart our timers
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, t5BacklightOnFromUserInput,
|
||||
"Press"); // Allow button to work while in serial API
|
||||
|
||||
// Handle critically low power battery by forcing deep sleep
|
||||
@@ -314,11 +349,13 @@ void PowerFSM_setup()
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
|
||||
|
||||
// Inputbroker
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput,
|
||||
"Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, t5BacklightOnFromUserInput,
|
||||
"Input Device"); // restarts the sleep timer
|
||||
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
|
||||
@@ -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 ", hour, min, sec, millis() / 1000);
|
||||
::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, 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 ", millis() / 1000);
|
||||
::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "../freertosinc.h"
|
||||
#include "Print.h"
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||
#include <Print.h>
|
||||
#include <stdarg.h>
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@ static File openFile(const char *filename, bool fullAtomic)
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
||||
#ifdef ARCH_NRF52
|
||||
FSCom.remove(filename);
|
||||
return FSCom.open(filename, FILE_O_WRITE);
|
||||
#endif
|
||||
if (!fullAtomic) {
|
||||
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
||||
}
|
||||
@@ -67,9 +63,6 @@ bool SafeFile::close()
|
||||
f.close();
|
||||
spiLock->unlock();
|
||||
|
||||
#ifdef ARCH_NRF52
|
||||
return true;
|
||||
#endif
|
||||
if (!testReadback())
|
||||
return false;
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ 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)
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel.
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
|
||||
RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,11 @@ Lock::Lock() : handle(xSemaphoreCreateBinary())
|
||||
}
|
||||
}
|
||||
|
||||
Lock::~Lock()
|
||||
{
|
||||
vSemaphoreDelete(handle);
|
||||
}
|
||||
|
||||
void Lock::lock()
|
||||
{
|
||||
if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
|
||||
@@ -30,6 +35,8 @@ void Lock::unlock()
|
||||
#else
|
||||
Lock::Lock() {}
|
||||
|
||||
Lock::~Lock() {}
|
||||
|
||||
void Lock::lock() {}
|
||||
|
||||
void Lock::unlock() {}
|
||||
|
||||
@@ -12,6 +12,7 @@ class Lock
|
||||
{
|
||||
public:
|
||||
Lock();
|
||||
~Lock();
|
||||
|
||||
Lock(const Lock &) = delete;
|
||||
Lock &operator=(const Lock &) = delete;
|
||||
|
||||
+13
-3
@@ -78,6 +78,11 @@ 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 1
|
||||
#endif
|
||||
|
||||
/// Convert a preprocessor name into a quoted string
|
||||
#define xstr(s) ystr(s)
|
||||
#define ystr(s) #s
|
||||
@@ -157,6 +162,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#elif defined(HELTEC_MESH_NODE_T096)
|
||||
#define NUM_PA_POINTS 22
|
||||
#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7
|
||||
#elif defined(HELTEC_V4_R8)
|
||||
#define NUM_PA_POINTS 22
|
||||
#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7
|
||||
#else
|
||||
// If a board enables USE_KCT8103L_PA but does not match a known variant and has
|
||||
// not already provided a PA curve, fail at compile time to avoid unsafe defaults.
|
||||
@@ -226,7 +234,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
|
||||
#define INA_ADDR 0x40 // same as SHT2X
|
||||
#define INA_ADDR_ALTERNATE 0x41
|
||||
#define INA_ADDR_WAVESHARE_UPS 0x43
|
||||
#define INA3221_ADDR 0x42
|
||||
@@ -239,8 +247,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 SHT31_4x_ADDR 0x44
|
||||
#define SHT31_4x_ADDR_ALT 0x45
|
||||
#define SHTXX_ADDR 0x44
|
||||
#define SHTXX_ADDR_ALT 0x45
|
||||
#define PMSA003I_ADDR 0x12
|
||||
#define QMA6100P_ADDR 0x12
|
||||
#define AHT10_ADDR 0x38
|
||||
@@ -494,6 +502,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_EXCLUDE_PKI 1
|
||||
#define MESHTASTIC_EXCLUDE_POWER_FSM 1
|
||||
#define MESHTASTIC_EXCLUDE_TZ 1
|
||||
#define MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH 1
|
||||
#endif
|
||||
|
||||
// Turn off all optional modules
|
||||
@@ -510,6 +519,7 @@ 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
|
||||
|
||||
@@ -11,7 +11,8 @@ enum LoRaRadioType {
|
||||
SX1280_RADIO,
|
||||
LR1110_RADIO,
|
||||
LR1120_RADIO,
|
||||
LR1121_RADIO
|
||||
LR1121_RADIO,
|
||||
LR2021_RADIO
|
||||
};
|
||||
|
||||
extern LoRaRadioType radioType;
|
||||
@@ -34,6 +34,7 @@ class ScanI2C
|
||||
SHT31,
|
||||
SHT4X,
|
||||
SHTC3,
|
||||
SHTXX,
|
||||
LPS22HB,
|
||||
QMC6310U,
|
||||
QMC6310N,
|
||||
@@ -96,7 +97,6 @@ class ScanI2C
|
||||
CW2015,
|
||||
SCD30,
|
||||
ADS1115,
|
||||
CST3530,
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
|
||||
@@ -136,7 +136,9 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
|
||||
return match;
|
||||
}
|
||||
|
||||
/// for SEN5X detection
|
||||
#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
|
||||
// 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
|
||||
@@ -174,6 +176,46 @@ 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: \
|
||||
@@ -371,27 +413,47 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
#endif
|
||||
#if !defined(M5STACK_UNITC6L)
|
||||
case INA_ADDR:
|
||||
case INA_ADDR: // Same as SHT2X
|
||||
case INA_ADDR_ALTERNATE:
|
||||
case INA_ADDR_WAVESHARE_UPS:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
LOG_DEBUG("Register MFG_UID: 0x%x", registerValue);
|
||||
if (registerValue == 0x5449) {
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
|
||||
LOG_DEBUG("Register DIE_UID: 0x%x", registerValue);
|
||||
case INA_ADDR_WAVESHARE_UPS: {
|
||||
uint16_t mfg = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
|
||||
if (registerValue == 0x2260) {
|
||||
LOG_DEBUG("Register MFG_UID: 0x%x", mfg);
|
||||
|
||||
// Only read DIE_UID for vendors we recognize as INA-compatible to avoid
|
||||
// an extra I2C transaction + delay on other devices sharing this address.
|
||||
if (mfg == 0x5449 || mfg == 0x190F) {
|
||||
uint16_t die = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
|
||||
LOG_DEBUG("Register DIE_UID: 0x%x", die);
|
||||
|
||||
// TI INA226 or fully compatible clones (e.g. TPA626)
|
||||
if (mfg == 0x5449 && die == 0x2260) {
|
||||
logFoundDevice("INA226", (uint8_t)addr.address);
|
||||
type = INA226;
|
||||
} else {
|
||||
}
|
||||
// Silergy SQ52201 (INA226-compatible with different IDs)
|
||||
else if (mfg == 0x190F && die == 0x0000) {
|
||||
logFoundDevice("INA226 (SQ52201)", (uint8_t)addr.address);
|
||||
type = INA226;
|
||||
}
|
||||
// TI INA260
|
||||
else if (mfg == 0x5449) {
|
||||
logFoundDevice("INA260", (uint8_t)addr.address);
|
||||
type = INA260;
|
||||
}
|
||||
} else { // Assume INA219 if INA260 ID is not found
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
if (type == NONE && 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
|
||||
logFoundDevice("INA219", (uint8_t)addr.address);
|
||||
type = INA219;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case INA3221_ADDR:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue);
|
||||
@@ -448,22 +510,19 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR
|
||||
case SHTXX_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHTXX_ADDR_ALT: // same as OPT3001_ADDR
|
||||
if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
|
||||
type = OPT3001;
|
||||
logFoundDevice("OPT3001", (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);
|
||||
} else { // SHTXX
|
||||
type = SHTXX;
|
||||
logFoundDevice("SHTXX", (uint8_t)addr.address);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address)
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address)
|
||||
case RCWL9620_ADDR:
|
||||
// get MAX30102 PARTID
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1);
|
||||
@@ -699,6 +758,7 @@ 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")) {
|
||||
@@ -714,6 +774,7 @@ 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);
|
||||
|
||||
+19
-3
@@ -103,6 +103,14 @@ 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)
|
||||
@@ -1017,10 +1025,13 @@ void GPS::up()
|
||||
setPowerState(GPS_ACTIVE);
|
||||
}
|
||||
|
||||
// We've got a GPS lock. Enter a low power state, potentially.
|
||||
// We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially.
|
||||
void GPS::down()
|
||||
{
|
||||
scheduling.informGotLock();
|
||||
if (hasValidLocation)
|
||||
scheduling.informGotLock();
|
||||
else
|
||||
scheduling.informSearchFailed();
|
||||
uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs();
|
||||
uint32_t sleepTime = scheduling.msUntilNextSearch();
|
||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
||||
@@ -1545,7 +1556,12 @@ std::unique_ptr<GPS> GPS::createGps()
|
||||
_en_gpio = PIN_GPS_EN;
|
||||
#endif
|
||||
#ifdef ARCH_PORTDUINO
|
||||
if (!portduino_config.has_gps)
|
||||
if (portduino_config.has_gps) {
|
||||
// These need to set as flags so later checks will pass on native and GPS will work.
|
||||
// They are not used for any hardware access.
|
||||
_rx_gpio = 1;
|
||||
_tx_gpio = 1;
|
||||
} else
|
||||
return nullptr;
|
||||
#endif
|
||||
if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all
|
||||
|
||||
@@ -15,6 +15,19 @@ void GPSUpdateScheduling::informGotLock()
|
||||
searchEndedMs = millis();
|
||||
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
|
||||
updateLockTimePrediction();
|
||||
consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix
|
||||
}
|
||||
|
||||
// Search finished without obtaining a fix. We still need to mark the end time so
|
||||
// the next sleep is timed correctly, but we must not feed the timeout duration
|
||||
// into predictedMsToGetLock — doing so poisons msUntilNextSearch() and causes
|
||||
// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles.
|
||||
void GPSUpdateScheduling::informSearchFailed()
|
||||
{
|
||||
searchEndedMs = millis();
|
||||
consecutiveFailures++;
|
||||
LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000,
|
||||
consecutiveFailures);
|
||||
}
|
||||
|
||||
// Clear old lock-time prediction data.
|
||||
@@ -25,6 +38,7 @@ void GPSUpdateScheduling::reset()
|
||||
searchEndedMs = 0;
|
||||
searchCount = 0;
|
||||
predictedMsToGetLock = 0;
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
// How many milliseconds before we should next search for GPS position
|
||||
@@ -36,6 +50,20 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch()
|
||||
// Target interval (seconds), between GPS updates
|
||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
|
||||
|
||||
// After a failed search, back off: indoors / no-sky environments will keep failing,
|
||||
// so wake at most once per broadcast interval rather than once per gps_update_interval.
|
||||
// Capped at 1 hour so a user-configured very-long broadcast interval still retries
|
||||
// periodically (in case conditions change). Reset on any successful lock.
|
||||
if (consecutiveFailures > 0) {
|
||||
constexpr uint32_t failureRetryCapMs = 60UL * 60UL * 1000UL; // 1 hour cap
|
||||
uint32_t failureSleepMs =
|
||||
Default::getConfiguredOrDefaultMs(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||
if (failureSleepMs > failureRetryCapMs)
|
||||
failureSleepMs = failureRetryCapMs;
|
||||
if (updateInterval < failureSleepMs)
|
||||
updateInterval = failureSleepMs;
|
||||
}
|
||||
|
||||
// Check how long until we should start searching, to hopefully hit our target interval
|
||||
uint32_t dueAtMs = searchEndedMs + updateInterval;
|
||||
uint32_t compensatedStart = dueAtMs - predictedMsToGetLock;
|
||||
@@ -70,20 +98,29 @@ bool GPSUpdateScheduling::isUpdateDue()
|
||||
// Have we been searching for a GPS position for too long?
|
||||
bool GPSUpdateScheduling::searchedTooLong()
|
||||
{
|
||||
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
|
||||
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
||||
constexpr uint32_t postFailureSearchMs = 5UL * oneMinuteMs; // Tighter dwell once we know the environment is hostile
|
||||
uint32_t elapsed = elapsedSearchMs();
|
||||
|
||||
// Anything over 15 minutes is too long, regardless of the broadcast interval.
|
||||
if (elapsed > maxSearchClampMs)
|
||||
return true;
|
||||
|
||||
// After a prior failed search, shorten the dwell
|
||||
if (consecutiveFailures > 0 && elapsed > postFailureSearchMs)
|
||||
return true;
|
||||
|
||||
uint32_t minimumOrConfiguredSecs =
|
||||
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
|
||||
// If broadcast interval set to max, no such thing as "too long"
|
||||
if (maxSearchMs == UINT32_MAX)
|
||||
return false;
|
||||
|
||||
// If we've been searching longer than our position broadcast interval: that's too long
|
||||
else if (elapsedSearchMs() > maxSearchMs)
|
||||
if (elapsed > maxSearchMs)
|
||||
return true;
|
||||
|
||||
// Otherwise, not too long yet!
|
||||
else
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation
|
||||
|
||||
@@ -8,7 +8,8 @@ class GPSUpdateScheduling
|
||||
public:
|
||||
// Marks the time of these events, for calculation use
|
||||
void informSearching();
|
||||
void informGotLock(); // Predicted lock-time is recalculated here
|
||||
void informGotLock(); // Predicted lock-time is recalculated here
|
||||
void informSearchFailed(); // Search ended without a fix; prediction is left untouched
|
||||
|
||||
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
|
||||
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
|
||||
@@ -24,6 +25,7 @@ class GPSUpdateScheduling
|
||||
uint32_t searchEndedMs = 0;
|
||||
uint32_t searchCount = 0;
|
||||
uint32_t predictedMsToGetLock = 0;
|
||||
uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock
|
||||
|
||||
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
|
||||
};
|
||||
+260
-77
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "draw/NodeListRenderer.h"
|
||||
#include "draw/NotificationRenderer.h"
|
||||
#include "draw/UIRenderer.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_GPS
|
||||
@@ -54,12 +55,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "graphics/images.h"
|
||||
#include "input/TouchScreenImpl1.h"
|
||||
#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"
|
||||
@@ -68,12 +71,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "target_specific.h"
|
||||
extern MessageStore messageStore;
|
||||
|
||||
#if USE_TFTDISPLAY
|
||||
extern uint16_t TFT_MESH;
|
||||
#else
|
||||
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
|
||||
#endif
|
||||
|
||||
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
||||
#include "mesh/wifi/WiFiAPClient.h"
|
||||
#endif
|
||||
@@ -98,6 +95,7 @@ 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
|
||||
@@ -107,6 +105,27 @@ namespace graphics
|
||||
// A text message frame + debug frame + all the node infos
|
||||
FrameCallback *normalFrames;
|
||||
static uint32_t targetFramerate = IDLE_FRAMERATE;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
static inline void prepareFrameColorRegions()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
clearTFTColorRegions();
|
||||
// Full-frame FrameMono inversion for themes that need it (e.g. light themes).
|
||||
if (isThemeFullFrameInvert()) {
|
||||
setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(),
|
||||
screen->getHeight());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline void updateUiFrame(OLEDDisplayUi *ui)
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
ui->update();
|
||||
}
|
||||
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
|
||||
|
||||
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
|
||||
@@ -135,6 +154,60 @@ 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
|
||||
// ==============================
|
||||
@@ -171,7 +244,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
// Called to trigger a banner with custom message and duration
|
||||
@@ -193,7 +266,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
// Called to trigger a banner with custom message and duration
|
||||
@@ -217,7 +290,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
|
||||
@@ -240,7 +313,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
@@ -272,10 +345,25 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
|
||||
float Screen::estimatedHeading(double lat, double lon)
|
||||
{
|
||||
static double oldLat, oldLon;
|
||||
static float b;
|
||||
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);
|
||||
|
||||
if (oldLat == 0) {
|
||||
// just prepare for next time
|
||||
// Need at least two position points before we can infer heading.
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
|
||||
@@ -283,12 +371,20 @@ float Screen::estimatedHeading(double lat, double lon)
|
||||
}
|
||||
|
||||
float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon);
|
||||
if (d < 10) // haven't moved enough, just keep current bearing
|
||||
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;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG;
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
lastHeadingAtMs = now;
|
||||
|
||||
return b;
|
||||
}
|
||||
@@ -309,30 +405,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
{
|
||||
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
|
||||
|
||||
int32_t rawRGB = uiconfig.screen_rgb_color;
|
||||
|
||||
// Only validate the combined value once
|
||||
if (rawRGB > 0 && rawRGB <= 255255255) {
|
||||
LOG_INFO("Setting screen RGB color to user chosen: 0x%06X", rawRGB);
|
||||
// Extract each component as a normal int first
|
||||
int r = (rawRGB >> 16) & 0xFF;
|
||||
int g = (rawRGB >> 8) & 0xFF;
|
||||
int b = rawRGB & 0xFF;
|
||||
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||
TFT_MESH = COLOR565(static_cast<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(b));
|
||||
}
|
||||
#ifdef TFT_MESH_OVERRIDE
|
||||
} else if (rawRGB == 0) {
|
||||
LOG_INFO("Setting screen RGB color to TFT_MESH_OVERRIDE: 0x%04X", TFT_MESH_OVERRIDE);
|
||||
// Default to TFT_MESH_OVERRIDE if available
|
||||
TFT_MESH = TFT_MESH_OVERRIDE;
|
||||
#endif
|
||||
} else {
|
||||
// Default best readable yellow color
|
||||
LOG_INFO("Setting screen RGB color to default: (255,255,128)");
|
||||
TFT_MESH = COLOR565(255, 255, 128);
|
||||
}
|
||||
|
||||
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
|
||||
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
|
||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||
@@ -395,9 +467,13 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
#endif
|
||||
|
||||
#if defined(USE_ST7789)
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
// Keep firmware and ST7789 driver region structs layout-compatible:
|
||||
// we pass `graphics::colorRegions` through a type cast below.
|
||||
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
|
||||
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
|
||||
#elif defined(USE_ST7796)
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
||||
#endif
|
||||
|
||||
ui = new OLEDDisplayUi(dispdev);
|
||||
@@ -448,6 +524,11 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
delay(100);
|
||||
#endif
|
||||
#if !ARCH_PORTDUINO
|
||||
#if defined(USE_ST7789) && defined(VTFT_CTRL)
|
||||
// Ensure panel power rail is enabled before sending wake commands.
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, LOW);
|
||||
#endif
|
||||
dispdev->displayOn();
|
||||
#endif
|
||||
|
||||
@@ -469,10 +550,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
ui->init();
|
||||
#endif
|
||||
#if defined(USE_ST7789) && defined(VTFT_LEDA)
|
||||
#ifdef VTFT_CTRL
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, LOW);
|
||||
#endif
|
||||
ui->init();
|
||||
#ifdef ESP_PLATFORM
|
||||
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
|
||||
@@ -513,23 +590,22 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
#endif
|
||||
#ifdef USE_ST7789
|
||||
SPI1.end();
|
||||
#if defined(ARCH_ESP32)
|
||||
// Keep TFT control pins in deterministic states while timed-off.
|
||||
// Floating/default pin states can corrupt panel edge rows on wake.
|
||||
#ifdef VTFT_LEDA
|
||||
pinMode(VTFT_LEDA, ANALOG);
|
||||
pinMode(VTFT_LEDA, OUTPUT);
|
||||
digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON);
|
||||
#endif
|
||||
#ifdef VTFT_CTRL
|
||||
pinMode(VTFT_CTRL, ANALOG);
|
||||
#endif
|
||||
pinMode(ST7789_RESET, ANALOG);
|
||||
pinMode(ST7789_RS, ANALOG);
|
||||
pinMode(ST7789_NSS, ANALOG);
|
||||
#else
|
||||
nrf_gpio_cfg_default(VTFT_LEDA);
|
||||
nrf_gpio_cfg_default(VTFT_CTRL);
|
||||
nrf_gpio_cfg_default(ST7789_RESET);
|
||||
nrf_gpio_cfg_default(ST7789_RS);
|
||||
nrf_gpio_cfg_default(ST7789_NSS);
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, HIGH);
|
||||
#endif
|
||||
pinMode(ST7789_RESET, OUTPUT);
|
||||
digitalWrite(ST7789_RESET, HIGH);
|
||||
pinMode(ST7789_RS, OUTPUT);
|
||||
digitalWrite(ST7789_RS, HIGH);
|
||||
pinMode(ST7789_NSS, OUTPUT);
|
||||
digitalWrite(ST7789_NSS, HIGH);
|
||||
#endif
|
||||
#ifdef USE_ST7796
|
||||
SPI1.end();
|
||||
@@ -584,16 +660,16 @@ void Screen::setup()
|
||||
static_cast<SH1106Wire *>(dispdev)->setSubtype(7);
|
||||
#endif
|
||||
|
||||
#if defined(USE_ST7789) && defined(TFT_MESH)
|
||||
// Apply custom RGB color (e.g. Heltec T114/T190)
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#if defined(USE_ST7789)
|
||||
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
|
||||
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
|
||||
#endif
|
||||
#if defined(MUZI_BASE)
|
||||
dispdev->delayPoweron = true;
|
||||
#endif
|
||||
#if defined(USE_ST7796) && defined(TFT_MESH)
|
||||
// Custom text color, if defined in variant.h
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#if defined(USE_ST7796)
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
||||
#endif
|
||||
|
||||
// Initialize display and UI system
|
||||
@@ -639,7 +715,7 @@ void Screen::setup()
|
||||
#endif
|
||||
{
|
||||
const char *region = myRegion ? myRegion->name : nullptr;
|
||||
graphics::UIRenderer::drawIconScreen(region, display, state, x, y);
|
||||
graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y);
|
||||
}
|
||||
};
|
||||
ui->setFrames(alertFrames, 1);
|
||||
@@ -678,9 +754,9 @@ void Screen::setup()
|
||||
// Turn on display and trigger first draw
|
||||
handleSetOn(true);
|
||||
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
#ifndef USE_EINK
|
||||
ui->update(); // Some SSD1306 clones drop the first draw, so run twice
|
||||
updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice
|
||||
#endif
|
||||
serialSinceMsec = millis();
|
||||
|
||||
@@ -753,7 +829,7 @@ void Screen::forceDisplay(bool forceUiUpdate)
|
||||
do {
|
||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||
delay(10);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||
|
||||
// Return to normal frame rate
|
||||
@@ -824,9 +900,9 @@ int32_t Screen::runOnce()
|
||||
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
|
||||
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
|
||||
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
#ifndef USE_EINK
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
#endif
|
||||
showingOEMBootScreen = false;
|
||||
}
|
||||
@@ -917,15 +993,28 @@ int32_t Screen::runOnce()
|
||||
|
||||
// this must be before the frameState == FIXED check, because we always
|
||||
// want to draw at least one FIXED frame before doing forceDisplay
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
|
||||
// Switch to a low framerate (to save CPU) when we are not in transition
|
||||
// but we should only call setTargetFPS when framestate changes, because
|
||||
// otherwise that breaks animations.
|
||||
|
||||
if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) {
|
||||
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) {
|
||||
// oldFrameState = ui->getUiState()->frameState;
|
||||
targetFramerate = IDLE_FRAMERATE;
|
||||
targetFramerate = desiredFramerate;
|
||||
|
||||
ui->setTargetFPS(targetFramerate);
|
||||
forceDisplay();
|
||||
@@ -966,7 +1055,7 @@ void Screen::setSSLFrames()
|
||||
// LOG_DEBUG("Show SSL frames");
|
||||
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
|
||||
ui->setFrames(sslFrames, 1);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,7 +1091,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
|
||||
do {
|
||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||
delay(1);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||
|
||||
#if defined(USE_EINK_PARALLELDISPLAY)
|
||||
@@ -1197,7 +1286,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::drawNodeInfo);
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1226,7 +1315,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 drawNodeInfo to pick a new node (because our list just changed)
|
||||
prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed)
|
||||
|
||||
// Focus on a specific frame, in the frame set we just created
|
||||
switch (focus) {
|
||||
@@ -1265,6 +1354,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
|
||||
}
|
||||
|
||||
@@ -1373,9 +1466,15 @@ void Screen::blink()
|
||||
dispdev->setBrightness(254);
|
||||
while (count > 0) {
|
||||
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
delay(50);
|
||||
dispdev->clear();
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
delay(50);
|
||||
count = count - 1;
|
||||
@@ -1419,11 +1518,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 {
|
||||
@@ -1443,6 +1608,9 @@ void Screen::setFastFramerate()
|
||||
{
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
dispdev->clear();
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
#endif
|
||||
// We are about to start a transition so speed up fps
|
||||
@@ -1654,7 +1822,7 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
setFastFramerate(); // Draw ASAP
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1669,7 +1837,7 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
setFastFramerate(); // Draw ASAP
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
|
||||
menuHandler::handleMenuSwitch(dispdev);
|
||||
return 0;
|
||||
@@ -1755,22 +1923,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) {
|
||||
|
||||
+13
-7
@@ -330,15 +330,11 @@ class Screen : public concurrency::OSThread
|
||||
|
||||
// Function to allow the AccelerometerThread to set the heading if a sensor provides it
|
||||
// Mutex needed?
|
||||
void setHeading(long _heading)
|
||||
{
|
||||
hasCompass = true;
|
||||
compassHeading = fmod(_heading, 360);
|
||||
}
|
||||
void setHeading(float heading);
|
||||
|
||||
bool hasHeading() { return hasCompass; }
|
||||
|
||||
long getHeading() { return compassHeading; }
|
||||
float getHeading() { return compassHeading; }
|
||||
|
||||
void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; }
|
||||
uint32_t getEndCalibration() { return endCalibrationAt; }
|
||||
@@ -673,6 +669,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
|
||||
@@ -782,4 +788,4 @@ extern std::vector<std::string> functionSymbol;
|
||||
extern std::string functionSymbolString;
|
||||
extern graphics::Screen *screen;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "RTC.h"
|
||||
#include "draw/NodeListRenderer.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/draw/UIRenderer.h"
|
||||
#include "main.h"
|
||||
#include "meshtastic/config.pb.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#include "power.h"
|
||||
#include <OLEDDisplay.h>
|
||||
#include <cctype>
|
||||
#include <graphics/images.h>
|
||||
|
||||
namespace graphics
|
||||
@@ -65,6 +69,12 @@ uint32_t lastBlinkShared = 0;
|
||||
bool isMailIconVisible = true;
|
||||
uint32_t lastMailBlink = 0;
|
||||
|
||||
static inline bool useClockHeaderAccentTheme(uint32_t themeId)
|
||||
{
|
||||
return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen ||
|
||||
themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
|
||||
}
|
||||
|
||||
// *********************************
|
||||
// * Rounded Header when inverted *
|
||||
// *********************************
|
||||
@@ -85,7 +95,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
|
||||
// *************************
|
||||
// * Common Header Drawing *
|
||||
// *************************
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date)
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date,
|
||||
bool transparent_background, bool use_title_color_override, uint16_t title_color_override)
|
||||
{
|
||||
constexpr int HEADER_OFFSET_Y = 1;
|
||||
y += HEADER_OFFSET_Y;
|
||||
@@ -100,30 +111,93 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
|
||||
const int screenW = display->getWidth();
|
||||
const int screenH = display->getHeight();
|
||||
const int headerHeight = highlightHeight + 2;
|
||||
const uint16_t headerColorForRoles = getThemeHeaderBg();
|
||||
// Color TFT headers use a fixed dark background + white glyphs.
|
||||
// Keep legacy inverted bitmap behavior only for monochrome displays.
|
||||
const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
int statusLeftEndX = 0;
|
||||
int statusRightStartX = screenW;
|
||||
const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0');
|
||||
const auto activeThemeId = getActiveTheme().id;
|
||||
const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId);
|
||||
#endif
|
||||
|
||||
{
|
||||
const uint16_t headerColor = getThemeHeaderBg();
|
||||
const uint16_t headerTextColor = getThemeHeaderText();
|
||||
const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor;
|
||||
uint16_t headerStatusColor = getThemeHeaderStatus();
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// Clock frame uses transparent header + date + empty title.
|
||||
// For accent clock themes (Pink/Creamsicle + classic monochrome), tint
|
||||
// status items (battery outline, %, date, mail icon) to the header accent.
|
||||
if (useClockHeaderAccent) {
|
||||
headerStatusColor = getThemeHeaderBg();
|
||||
}
|
||||
|
||||
if (transparent_background) {
|
||||
// Transparent clock headers should inherit whatever body off-color is
|
||||
// already active under the header (important for light/inverted themes).
|
||||
const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg());
|
||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW,
|
||||
headerHeight);
|
||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor);
|
||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor);
|
||||
} else if (useInvertedHeaderStyle) {
|
||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW,
|
||||
headerHeight);
|
||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole);
|
||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor);
|
||||
} else {
|
||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW,
|
||||
headerHeight);
|
||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor);
|
||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!force_no_invert) {
|
||||
// === Inverted Header Background ===
|
||||
if (isInverted) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, 0, screenW, highlightHeight + 2);
|
||||
display->fillRect(0, 0, screenW, headerHeight);
|
||||
display->setColor(WHITE);
|
||||
drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
|
||||
display->setColor(BLACK);
|
||||
} else {
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, 0, screenW, highlightHeight + 2);
|
||||
display->setColor(WHITE);
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(0, 20, screenW, 20);
|
||||
} else {
|
||||
display->drawLine(0, 14, screenW, 14);
|
||||
display->fillRect(0, 0, screenW, headerHeight);
|
||||
// Keep the legacy white separator for monochrome displays only when header background is visible.
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (!transparent_background) {
|
||||
display->setColor(WHITE);
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(0, 20, screenW, 20);
|
||||
} else {
|
||||
display->drawLine(0, 14, screenW, 14);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (transparent_background) {
|
||||
display->setColor(WHITE);
|
||||
}
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// TFT role coloring expects foreground glyph bits to be "set".
|
||||
display->setColor(WHITE);
|
||||
#endif
|
||||
|
||||
// === Screen Title ===
|
||||
const char *headerTitle = titleStr ? titleStr : "";
|
||||
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
|
||||
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2);
|
||||
registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL);
|
||||
#endif
|
||||
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
|
||||
}
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
@@ -152,6 +226,17 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
|
||||
bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH);
|
||||
const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
|
||||
bool hasBatteryFillRegion = false;
|
||||
int16_t batteryFillRegionX = 0;
|
||||
int16_t batteryFillRegionY = 0;
|
||||
int16_t batteryFillRegionW = 0;
|
||||
int16_t batteryFillRegionH = 0;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent);
|
||||
if (useClockHeaderAccent) {
|
||||
batteryFillColor = getThemeHeaderBg();
|
||||
}
|
||||
#endif
|
||||
|
||||
int batteryX = 1;
|
||||
int batteryY = HEADER_OFFSET_Y + 1;
|
||||
@@ -180,6 +265,15 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12);
|
||||
int fillWidth = 14 * chargePercent / 100;
|
||||
display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (fillWidth > 0) {
|
||||
hasBatteryFillRegion = true;
|
||||
batteryFillRegionX = batteryX + 1;
|
||||
batteryFillRegionY = batteryY + 1;
|
||||
batteryFillRegionW = fillWidth;
|
||||
batteryFillRegionH = 11;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
batteryX += 18; // Icon + 2 pixels
|
||||
} else {
|
||||
@@ -194,21 +288,41 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int fillHeight = 8 * chargePercent / 100;
|
||||
int fillY = batteryY - fillHeight;
|
||||
display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (fillHeight > 0) {
|
||||
hasBatteryFillRegion = true;
|
||||
batteryFillRegionX = batteryX + 1;
|
||||
batteryFillRegionY = fillY + 10;
|
||||
batteryFillRegionW = 5;
|
||||
batteryFillRegionH = fillHeight;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
batteryX += 9; // Icon + 2 pixels
|
||||
}
|
||||
}
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = batteryX + 2;
|
||||
#endif
|
||||
|
||||
if (chargePercent != 101) {
|
||||
// === Battery % Display ===
|
||||
char chargeStr[4];
|
||||
snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent);
|
||||
int chargeNumWidth = display->getStringWidth(chargeStr);
|
||||
const int percentWidth = display->getStringWidth("%");
|
||||
const int percentX = batteryX + chargeNumWidth - 1;
|
||||
display->drawString(batteryX, textY, chargeStr);
|
||||
display->drawString(batteryX + chargeNumWidth - 1, textY, "%");
|
||||
display->drawString(percentX, textY, "%");
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = percentX + percentWidth + 2;
|
||||
#endif
|
||||
if (isBold) {
|
||||
display->drawString(batteryX + 1, textY, chargeStr);
|
||||
display->drawString(batteryX + chargeNumWidth, textY, "%");
|
||||
display->drawString(percentX + 1, textY, "%");
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = percentX + percentWidth + 3;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +367,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
timeStrWidth = display->getStringWidth(timeStr);
|
||||
}
|
||||
timeX = screenW - xOffset - timeStrWidth + 3;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16);
|
||||
#endif
|
||||
|
||||
// === Show Mail or Mute Icon to the Left of Time ===
|
||||
int iconRightEdge = timeX - 2;
|
||||
@@ -278,7 +395,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int iconW = 16, iconH = 12;
|
||||
int iconX = iconRightEdge - iconW;
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -293,7 +410,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
} else {
|
||||
int iconX = iconRightEdge - (mail_width - 2);
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -309,7 +426,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int iconX = iconRightEdge - mute_symbol_big_width;
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
|
||||
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -323,7 +440,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int iconX = iconRightEdge - mute_symbol_width;
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
||||
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -351,7 +468,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
} else {
|
||||
// === No Time Available: Mail/Mute Icon Moves to Far Right ===
|
||||
int iconRightEdge = screenW - xOffset;
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12);
|
||||
#endif
|
||||
bool showMail = false;
|
||||
|
||||
#ifndef USE_EINK
|
||||
@@ -393,6 +512,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight);
|
||||
if (statusRightStartX < screenW) {
|
||||
registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight);
|
||||
}
|
||||
if (hasBatteryFillRegion) {
|
||||
registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH,
|
||||
batteryFillColor, headerColorForRoles);
|
||||
}
|
||||
#endif
|
||||
display->setColor(WHITE); // Reset for other UI
|
||||
}
|
||||
@@ -430,14 +559,23 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
return;
|
||||
|
||||
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
|
||||
const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale);
|
||||
const int footerH = (connection_icon_height * scale) + (2 * scale);
|
||||
const int iconX = 0;
|
||||
const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale);
|
||||
const int iconW = connection_icon_width * scale;
|
||||
const int iconH = connection_icon_height * scale;
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// Only tint the link glyph itself on TFT; keep the footer background black.
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH);
|
||||
#endif
|
||||
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
|
||||
(connection_icon_height * scale) + (2 * scale));
|
||||
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
|
||||
display->setColor(WHITE);
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
const int bytesPerRow = (connection_icon_width + 7) / 8;
|
||||
int iconX = 0;
|
||||
int iconY = SCREEN_HEIGHT - (connection_icon_height * 2);
|
||||
|
||||
for (int yy = 0; yy < connection_icon_height; ++yy) {
|
||||
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
|
||||
@@ -451,65 +589,127 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
}
|
||||
|
||||
} else {
|
||||
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
|
||||
connection_icon);
|
||||
display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon);
|
||||
}
|
||||
}
|
||||
|
||||
bool isAllowedPunctuation(char c)
|
||||
{
|
||||
const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ ";
|
||||
return allowed.find(c) != std::string::npos;
|
||||
switch (c) {
|
||||
case '.':
|
||||
case ',':
|
||||
case '!':
|
||||
case '?':
|
||||
case ';':
|
||||
case ':':
|
||||
case '-':
|
||||
case '_':
|
||||
case '(':
|
||||
case ')':
|
||||
case '[':
|
||||
case ']':
|
||||
case '{':
|
||||
case '}':
|
||||
case '\'':
|
||||
case '"':
|
||||
case '@':
|
||||
case '#':
|
||||
case '$':
|
||||
case '/':
|
||||
case '\\':
|
||||
case '&':
|
||||
case '+':
|
||||
case '=':
|
||||
case '%':
|
||||
case '~':
|
||||
case '^':
|
||||
case ' ':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void replaceAll(std::string &s, const std::string &from, const std::string &to)
|
||||
static inline size_t utf8CodePointLength(unsigned char lead)
|
||||
{
|
||||
if (from.empty())
|
||||
return;
|
||||
size_t pos = 0;
|
||||
while ((pos = s.find(from, pos)) != std::string::npos) {
|
||||
s.replace(pos, from.size(), to);
|
||||
pos += to.size();
|
||||
if ((lead & 0x80) == 0x00) {
|
||||
return 1;
|
||||
}
|
||||
if ((lead & 0xE0) == 0xC0) {
|
||||
return 2;
|
||||
}
|
||||
if ((lead & 0xF0) == 0xE0) {
|
||||
return 3;
|
||||
}
|
||||
if ((lead & 0xF8) == 0xF0) {
|
||||
return 4;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string sanitizeString(const std::string &input)
|
||||
{
|
||||
static constexpr char kReplacementChar = static_cast<char>(0xBF); // Inverted question mark in ISO-8859-1.
|
||||
std::string output;
|
||||
output.reserve(input.size());
|
||||
bool inReplacement = false;
|
||||
|
||||
// Make a mutable copy so we can normalize UTF-8 “smart punctuation” into ASCII first.
|
||||
std::string s = input;
|
||||
|
||||
// Curly single quotes: ‘ ’
|
||||
replaceAll(s, "\xE2\x80\x98", "'"); // U+2018
|
||||
replaceAll(s, "\xE2\x80\x99", "'"); // U+2019
|
||||
|
||||
// Curly double quotes: “ ”
|
||||
replaceAll(s, "\xE2\x80\x9C", "\""); // U+201C
|
||||
replaceAll(s, "\xE2\x80\x9D", "\""); // U+201D
|
||||
|
||||
// En dash / Em dash: – —
|
||||
replaceAll(s, "\xE2\x80\x93", "-"); // U+2013
|
||||
replaceAll(s, "\xE2\x80\x94", "-"); // U+2014
|
||||
|
||||
// Non-breaking space
|
||||
replaceAll(s, "\xC2\xA0", " "); // U+00A0
|
||||
|
||||
// Now do your original sanitize pass over the normalized string.
|
||||
for (unsigned char uc : s) {
|
||||
char c = static_cast<char>(uc);
|
||||
if (std::isalnum(uc) || isAllowedPunctuation(c)) {
|
||||
output += c;
|
||||
inReplacement = false;
|
||||
} else {
|
||||
const size_t inputSize = input.size();
|
||||
size_t i = 0;
|
||||
while (i < inputSize) {
|
||||
const unsigned char byte0 = static_cast<unsigned char>(input[i]);
|
||||
char normalized = '\0';
|
||||
size_t consumed = 0;
|
||||
if (byte0 < 0x80) {
|
||||
normalized = static_cast<char>(byte0);
|
||||
consumed = 1;
|
||||
} else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast<unsigned char>(input[i + 1]) == 0x80) {
|
||||
// Smart punctuation: ' ' \" \" - -
|
||||
switch (static_cast<unsigned char>(input[i + 2])) {
|
||||
case 0x98:
|
||||
case 0x99:
|
||||
normalized = '\'';
|
||||
consumed = 3;
|
||||
break;
|
||||
case 0x9C:
|
||||
case 0x9D:
|
||||
normalized = '\"';
|
||||
consumed = 3;
|
||||
break;
|
||||
case 0x93:
|
||||
case 0x94:
|
||||
normalized = '-';
|
||||
consumed = 3;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast<unsigned char>(input[i + 1]) == 0xA0) {
|
||||
// Non-breaking space.
|
||||
normalized = ' ';
|
||||
consumed = 2;
|
||||
}
|
||||
if (consumed == 0) {
|
||||
size_t seqLen = utf8CodePointLength(byte0);
|
||||
if (seqLen > (inputSize - i)) {
|
||||
seqLen = 1;
|
||||
}
|
||||
if (!inReplacement) {
|
||||
output += static_cast<char>(0xBF); // ISO-8859-1 for inverted question mark
|
||||
output.push_back(kReplacementChar);
|
||||
inReplacement = true;
|
||||
}
|
||||
i += seqLen;
|
||||
continue;
|
||||
}
|
||||
const unsigned char normalizedUc = static_cast<unsigned char>(normalized);
|
||||
if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) {
|
||||
output.push_back(normalized);
|
||||
inReplacement = false;
|
||||
} else if (!inReplacement) {
|
||||
output.push_back(kReplacementChar);
|
||||
inReplacement = true;
|
||||
}
|
||||
i += consumed;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <OLEDDisplay.h>
|
||||
#include <stdint.h>
|
||||
#include <string>
|
||||
|
||||
namespace graphics
|
||||
@@ -52,7 +53,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
|
||||
|
||||
// Shared battery/time/mail header
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false,
|
||||
bool show_date = false);
|
||||
bool show_date = false, bool transparent_background = false, bool use_title_color_override = false,
|
||||
uint16_t title_color_override = 0);
|
||||
|
||||
// Shared battery/time/mail header
|
||||
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
#include "TFTColorRegions.h"
|
||||
#include "NodeDB.h"
|
||||
#include "TFTPalette.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
struct TFTRoleColorsBe {
|
||||
uint16_t onColorBe;
|
||||
uint16_t offColorBe;
|
||||
};
|
||||
|
||||
static uint8_t colorRegionCount = 0;
|
||||
static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u;
|
||||
static constexpr uint32_t kFnv1aPrime = 16777619u;
|
||||
|
||||
static constexpr uint16_t toBe565(uint16_t color)
|
||||
{
|
||||
return static_cast<uint16_t>((color >> 8) | (color << 8));
|
||||
}
|
||||
|
||||
static constexpr bool kRoleIsBody[static_cast<size_t>(TFTColorRole::Count)] = {
|
||||
false, // HeaderBackground
|
||||
false, // HeaderTitle
|
||||
false, // HeaderStatus
|
||||
true, // SignalBars
|
||||
true, // ConnectionIcon
|
||||
true, // UtilizationFill
|
||||
true, // FavoriteNode
|
||||
true, // ActionMenuBorder
|
||||
true, // ActionMenuBody
|
||||
true, // ActionMenuTitle
|
||||
true, // FrameMono
|
||||
false, // BootSplash
|
||||
true, // FavoriteNodeBGHighlight
|
||||
false, // NavigationBar
|
||||
false // NavigationArrow
|
||||
};
|
||||
|
||||
static inline bool isBodyColorRole(TFTColorRole role)
|
||||
{
|
||||
return kRoleIsBody[static_cast<size_t>(role)];
|
||||
}
|
||||
|
||||
static inline bool isMonochromeTheme(uint32_t themeId)
|
||||
{
|
||||
return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
|
||||
}
|
||||
|
||||
static inline uint16_t getMonochromeAccent(uint32_t themeId)
|
||||
{
|
||||
return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen
|
||||
: (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed
|
||||
: TFTPalette::White;
|
||||
}
|
||||
|
||||
static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to)
|
||||
{
|
||||
if (value == from) {
|
||||
value = to;
|
||||
}
|
||||
}
|
||||
|
||||
static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value)
|
||||
{
|
||||
return (hash ^ value) * kFnv1aPrime;
|
||||
}
|
||||
|
||||
static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value)
|
||||
{
|
||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>(value & 0xFF));
|
||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>((value >> 8) & 0xFF));
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Compile-time header color overrides (backward-compatible)
|
||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kTitleColor = TFTPalette::White;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kStatusColor = TFTPalette::White;
|
||||
#endif
|
||||
|
||||
// Theme definitions
|
||||
// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color
|
||||
// against each entry's .uniqueIdentifier field.
|
||||
|
||||
static const TFTThemeDef kThemes[] = {
|
||||
|
||||
// Default Dark (ThemeID::DefaultDark = 0)
|
||||
{
|
||||
ThemeID::DefaultDark, // id
|
||||
"Default Dark", // name
|
||||
0, // uniqueIdentifier
|
||||
// roles[TFTColorRole::Count]
|
||||
{
|
||||
{kHeaderBackground, TFTPalette::Black}, // HeaderBackground
|
||||
{kHeaderBackground, kTitleColor}, // HeaderTitle
|
||||
{kHeaderBackground, kStatusColor}, // HeaderStatus
|
||||
{TFTPalette::Good, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::Good, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg)
|
||||
{kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::Good, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
false, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Default Light (ThemeID::DefaultLight = 1)
|
||||
{
|
||||
ThemeID::DefaultLight, // id
|
||||
"Default Light", // name
|
||||
1, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::Good, TFTPalette::White}, // SignalBars
|
||||
{TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon
|
||||
{TFTPalette::Good, TFTPalette::White}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode
|
||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::White}, // ActionMenuBody
|
||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::Good, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Christmas (ThemeID::Christmas = 2)
|
||||
{
|
||||
ThemeID::Christmas, // id
|
||||
"Christmas", // name
|
||||
2, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus
|
||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon
|
||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody
|
||||
{TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Pine, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::ChristmasGreen, // batteryFillGood
|
||||
TFTPalette::Gold, // batteryFillMedium
|
||||
TFTPalette::ChristmasRed, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
false, // visible
|
||||
},
|
||||
|
||||
// Pink (ThemeID::Pink = 3) light variant
|
||||
{
|
||||
ThemeID::Pink, // id
|
||||
"Pink", // name
|
||||
3, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus
|
||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon
|
||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::HotPink}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::DeepPink, // batteryFillGood
|
||||
TFTPalette::HotPink, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Blue (ThemeID::Blue = 4) dark variant
|
||||
{
|
||||
ThemeID::Blue, // id
|
||||
"Blue", // name
|
||||
4, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode
|
||||
{TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody
|
||||
{TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Navy, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::SkyBlue, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Creamsicle (ThemeID::Creamsicle = 5)light variant
|
||||
{
|
||||
ThemeID::Creamsicle, // id
|
||||
"Creamsicle", // name
|
||||
5, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus
|
||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars
|
||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon
|
||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode
|
||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::DeepOrange, // batteryFillGood
|
||||
TFTPalette::Gold, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome
|
||||
// Pure single-color-on-black look. Every role maps foreground pixels to
|
||||
// the theme color and background pixels to Black.
|
||||
{
|
||||
ThemeID::MeshtasticGreen, // id
|
||||
"Meshtastic Green", // name
|
||||
6, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Classic Red (ThemeID::ClassicRed = 7) classic monochrome
|
||||
{
|
||||
ThemeID::ClassicRed, // id
|
||||
"Classic Red", // name
|
||||
7, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome
|
||||
{
|
||||
ThemeID::MonochromeWhite, // id
|
||||
"Monochrome White", // name
|
||||
8, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::White, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::White, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::White, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::White, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
};
|
||||
|
||||
static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]);
|
||||
|
||||
// Resolve the kThemes[] index for the currently persisted theme. Called at
|
||||
// boot (indirectly via getActiveTheme()) and whenever the active theme is
|
||||
// queried, so uiconfig.screen_rgb_color remains the single source of truth.
|
||||
// Matches against .uniqueIdentifier - that's the field whose value is stored
|
||||
// in the user's config. Falls back to 0 (DefaultDark) if no match is found,
|
||||
// which gracefully handles removed or retired themes.
|
||||
static inline size_t resolveThemeIndex()
|
||||
{
|
||||
const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (kThemes[i].uniqueIdentifier == savedIdentifier)
|
||||
return i;
|
||||
}
|
||||
return 0; // Default Dark fallback
|
||||
}
|
||||
|
||||
static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (x < 0) {
|
||||
width += x;
|
||||
x = 0;
|
||||
}
|
||||
if (y < 0) {
|
||||
height += y;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
|
||||
static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe)
|
||||
{
|
||||
// Keep the last slot permanently disabled as a sentinel for ST7789 scans.
|
||||
// This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries.
|
||||
if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) {
|
||||
memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2));
|
||||
colorRegionCount = MAX_TFT_COLOR_REGIONS - 2;
|
||||
}
|
||||
|
||||
TFTColorRegion ®ion = colorRegions[colorRegionCount++];
|
||||
region.x = x;
|
||||
region.y = y;
|
||||
region.width = width;
|
||||
region.height = height;
|
||||
region.onColorBe = onColorBe;
|
||||
region.offColorBe = offColorBe;
|
||||
region.enabled = true;
|
||||
|
||||
// Keep one disabled sentinel after the active range for ST7789 countColorRegions().
|
||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
||||
colorRegions[colorRegionCount].enabled = false;
|
||||
}
|
||||
colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false;
|
||||
}
|
||||
|
||||
// Current working role colors (big-endian). Initialised to Dark defaults;
|
||||
// call loadThemeDefaults() after boot / theme change to refresh.
|
||||
static TFTRoleColorsBe roleColors[static_cast<size_t>(TFTColorRole::Count)] = {
|
||||
{toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground
|
||||
{toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle
|
||||
{toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus
|
||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars
|
||||
{toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon
|
||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill
|
||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode
|
||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder
|
||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody
|
||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle
|
||||
{toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono
|
||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash
|
||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight
|
||||
{toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar
|
||||
{toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Theme accessors
|
||||
|
||||
const TFTThemeDef &getActiveTheme()
|
||||
{
|
||||
return kThemes[resolveThemeIndex()];
|
||||
}
|
||||
|
||||
// Visible-theme accessors
|
||||
// These iterate only themes flagged .visible = true, preserving kThemes[]
|
||||
// order. Menu code should use these so hidden themes don't appear in the
|
||||
// picker while still applying correctly if their ID is persisted.
|
||||
|
||||
size_t getVisibleThemeCount()
|
||||
{
|
||||
size_t count = 0;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (kThemes[i].visible)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex)
|
||||
{
|
||||
size_t seen = 0;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (!kThemes[i].visible)
|
||||
continue;
|
||||
if (seen == visibleIndex)
|
||||
return kThemes[i];
|
||||
seen++;
|
||||
}
|
||||
// Fallback: return first theme (never trust a bad index).
|
||||
return kThemes[0];
|
||||
}
|
||||
|
||||
size_t getActiveVisibleThemeIndex()
|
||||
{
|
||||
const size_t active = resolveThemeIndex();
|
||||
if (!kThemes[active].visible)
|
||||
return SIZE_MAX;
|
||||
size_t visibleIdx = 0;
|
||||
for (size_t i = 0; i < active; i++) {
|
||||
if (kThemes[i].visible)
|
||||
visibleIdx++;
|
||||
}
|
||||
return visibleIdx;
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderBg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
||||
return TFT_HEADER_BG_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderBackground)].onColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::DarkGray;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderText()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
||||
return TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderTitle)].offColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderStatus()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
||||
return TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderStatus)].offColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBodyBg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].onColor;
|
||||
#else
|
||||
return TFTPalette::Black;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBodyFg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].offColor;
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isThemeFullFrameInvert()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].fullFrameInvert;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBatteryFillColor(int batteryPercent)
|
||||
{
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
if (batteryPercent <= 20) {
|
||||
return theme.batteryFillBad;
|
||||
}
|
||||
if (batteryPercent <= 50) {
|
||||
return theme.batteryFillMedium;
|
||||
}
|
||||
return theme.batteryFillGood;
|
||||
}
|
||||
|
||||
void loadThemeDefaults()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
for (uint8_t i = 0; i < static_cast<uint8_t>(TFTColorRole::Count); i++) {
|
||||
roleColors[i].onColorBe = toBe565(theme.roles[i].onColor);
|
||||
roleColors[i].offColorBe = toBe565(theme.roles[i].offColor);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Role color assignment with theme-aware transforms
|
||||
|
||||
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
const uint8_t index = static_cast<uint8_t>(role);
|
||||
if (index >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F;
|
||||
const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight);
|
||||
const bool isBodyRole = !isHighlightRole && isBodyColorRole(role);
|
||||
|
||||
// Classic monochrome themes collapse all non-black accents into one tone.
|
||||
if (isMonochromeTheme(themeId)) {
|
||||
if (onColor != TFTPalette::Black) {
|
||||
onColor = getMonochromeAccent(themeId);
|
||||
}
|
||||
} else {
|
||||
switch (themeId) {
|
||||
case ThemeID::DefaultLight:
|
||||
if (isHighlightRole) {
|
||||
// High-contrast highlight chips on light UI.
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::Yellow;
|
||||
} else if (isBodyRole) {
|
||||
// Invert body colors for readability on white frames.
|
||||
if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) {
|
||||
offColor = TFTPalette::White;
|
||||
}
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Christmas:
|
||||
if (isHighlightRole || isBodyRole) {
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold);
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Pink:
|
||||
if (isHighlightRole) {
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::HotPink;
|
||||
} else if (isBodyRole) {
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink);
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Creamsicle:
|
||||
if (isHighlightRole) {
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::CreamOrange;
|
||||
} else if (isBodyRole) {
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream);
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Blue:
|
||||
if (isHighlightRole || isBodyRole) {
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue);
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
roleColors[index].onColorBe = toBe565(onColor);
|
||||
roleColors[index].offColorBe = toBe565(offColor);
|
||||
}
|
||||
|
||||
// Region registration
|
||||
|
||||
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
const uint8_t roleIndex = static_cast<uint8_t>(role);
|
||||
if (roleIndex >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizeRegion(x, y, width, height)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const TFTRoleColorsBe &colors = roleColors[roleIndex];
|
||||
appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe);
|
||||
}
|
||||
|
||||
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
|
||||
int16_t height)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)role;
|
||||
(void)onColor;
|
||||
(void)offColor;
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)width;
|
||||
(void)height;
|
||||
return;
|
||||
#else
|
||||
setTFTColorRole(role, onColor, offColor);
|
||||
registerTFTColorRegion(role, x, y, width, height);
|
||||
#endif
|
||||
}
|
||||
|
||||
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
if (!normalizeRegion(x, y, width, height))
|
||||
return;
|
||||
|
||||
appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor));
|
||||
}
|
||||
|
||||
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)boxLeft;
|
||||
(void)boxTop;
|
||||
(void)boxWidth;
|
||||
(void)boxHeight;
|
||||
return;
|
||||
#else
|
||||
// Use theme-appropriate menu colors.
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
const TFTThemeRoleColor &menuBody = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBody)];
|
||||
const TFTThemeRoleColor &menuBorder = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBorder)];
|
||||
|
||||
// Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly.
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1,
|
||||
boxWidth + 2, boxHeight + 2);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight);
|
||||
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth,
|
||||
1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Frame signature & utilities
|
||||
|
||||
uint32_t getTFTColorFrameSignature()
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return 0;
|
||||
#else
|
||||
uint32_t hash = kFnv1aOffsetBasis;
|
||||
hash = fnv1aAppendByte(hash, colorRegionCount);
|
||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
||||
const TFTColorRegion &r = colorRegions[i];
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.x));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.y));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.width));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.height));
|
||||
hash = fnv1aAppendU16(hash, r.onColorBe);
|
||||
hash = fnv1aAppendU16(hash, r.offColorBe);
|
||||
}
|
||||
|
||||
return hash;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint8_t getTFTColorRegionCount()
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return 0;
|
||||
#else
|
||||
return colorRegionCount;
|
||||
#endif
|
||||
}
|
||||
|
||||
void clearTFTColorRegions()
|
||||
{
|
||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
||||
colorRegions[i].enabled = false;
|
||||
}
|
||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
||||
colorRegions[colorRegionCount].enabled = false;
|
||||
}
|
||||
colorRegionCount = 0;
|
||||
}
|
||||
|
||||
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
|
||||
{
|
||||
for (int i = static_cast<int>(colorRegionCount) - 1; i >= 0; i--) {
|
||||
const TFTColorRegion &r = colorRegions[i];
|
||||
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
|
||||
return isset ? r.onColorBe : r.offColorBe;
|
||||
}
|
||||
}
|
||||
return isset ? defaultOnColor : defaultOffColor;
|
||||
}
|
||||
|
||||
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)x;
|
||||
(void)y;
|
||||
return defaultOffColor;
|
||||
#else
|
||||
const uint16_t defaultOffBe = toBe565(defaultOffColor);
|
||||
const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe);
|
||||
return static_cast<uint16_t>((sampledBe >> 8) | (sampledBe << 8));
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
@@ -0,0 +1,163 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
#include <stdint.h>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
|
||||
struct TFTColorRegion {
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
uint16_t onColorBe;
|
||||
uint16_t offColorBe;
|
||||
// Required by ST7789 driver: it scans until the first disabled entry.
|
||||
bool enabled = false;
|
||||
};
|
||||
|
||||
static constexpr size_t MAX_TFT_COLOR_REGIONS = 48;
|
||||
extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
|
||||
|
||||
enum class TFTColorRole : uint8_t {
|
||||
HeaderBackground = 0,
|
||||
HeaderTitle,
|
||||
HeaderStatus,
|
||||
SignalBars,
|
||||
ConnectionIcon,
|
||||
UtilizationFill,
|
||||
FavoriteNode,
|
||||
ActionMenuBorder,
|
||||
ActionMenuBody,
|
||||
ActionMenuTitle,
|
||||
FrameMono,
|
||||
BootSplash,
|
||||
FavoriteNodeBGHighlight,
|
||||
NavigationBar,
|
||||
NavigationArrow,
|
||||
Count
|
||||
};
|
||||
|
||||
#if HAS_TFT || defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \
|
||||
defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) || \
|
||||
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)
|
||||
#define GRAPHICS_TFT_COLORING_ENABLED 1
|
||||
#else
|
||||
#define GRAPHICS_TFT_COLORING_ENABLED 0
|
||||
#endif
|
||||
|
||||
static constexpr bool kTFTColoringEnabled = GRAPHICS_TFT_COLORING_ENABLED != 0;
|
||||
constexpr bool isTFTColoringEnabled()
|
||||
{
|
||||
return kTFTColoringEnabled;
|
||||
}
|
||||
|
||||
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor);
|
||||
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height);
|
||||
// Convenience helper for the common "set role then register one region" flow.
|
||||
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
|
||||
int16_t height);
|
||||
// Register a region using explicit colors (no role lookup). Use when the
|
||||
// color comes from a theme field rather than a role (e.g. battery fill).
|
||||
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor);
|
||||
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight);
|
||||
uint32_t getTFTColorFrameSignature();
|
||||
uint8_t getTFTColorRegionCount();
|
||||
void clearTFTColorRegions();
|
||||
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor);
|
||||
// Resolve effective region-mapped OFF color at a coordinate in native-endian RGB565.
|
||||
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor);
|
||||
|
||||
// -- Theme engine ------------------------------------------------------
|
||||
// Each theme has four fields that work together:
|
||||
//
|
||||
// id - ThemeID:: constant, used for in-code references.
|
||||
// name - human-readable label shown in the theme picker.
|
||||
// uniqueIdentifier - the stable numeric value persisted to
|
||||
// uiconfig.screen_rgb_color and restored at boot.
|
||||
// This is a CONTRACT with saved configs on disk - once
|
||||
// assigned, never reuse or renumber, even if the theme is
|
||||
// deleted or the kThemes[] array is reordered.
|
||||
// visible - controls whether a theme appears in the picker menu.
|
||||
// Hidden themes can still be restored and applied if their
|
||||
// uniqueIdentifier is persisted.
|
||||
//
|
||||
// Display order in the menu is controlled by kThemes[] array order among
|
||||
// themes where visible == true, NOT by any numeric value above.
|
||||
//
|
||||
// To add a new theme:
|
||||
// 1. Add a unique constant in ThemeID below (next unused value).
|
||||
// 2. Add a kThemes[] entry at the desired menu position, with a unique
|
||||
// uniqueIdentifier that has never been used by any prior theme.
|
||||
// 3. Set visible=true if it should appear in the picker.
|
||||
//
|
||||
// To retire a theme without breaking saved configs:
|
||||
// - Preferred: keep the entry and set visible=false so existing saved
|
||||
// uniqueIdentifier values still resolve to the same theme.
|
||||
// - If you remove the entry, resolveThemeIndex() falls back to DefaultDark
|
||||
// when the persisted uniqueIdentifier no longer matches any theme.
|
||||
// - Do NOT reuse a retired uniqueIdentifier for a future theme.
|
||||
namespace ThemeID
|
||||
{
|
||||
constexpr uint32_t DefaultDark = 0;
|
||||
constexpr uint32_t DefaultLight = 1;
|
||||
constexpr uint32_t Christmas = 2;
|
||||
constexpr uint32_t Pink = 3;
|
||||
constexpr uint32_t Blue = 4;
|
||||
constexpr uint32_t Creamsicle = 5;
|
||||
constexpr uint32_t MeshtasticGreen = 6;
|
||||
constexpr uint32_t ClassicRed = 7;
|
||||
constexpr uint32_t MonochromeWhite = 8;
|
||||
} // namespace ThemeID
|
||||
|
||||
// Per-role color pair stored in native (little-endian) RGB565 format.
|
||||
struct TFTThemeRoleColor {
|
||||
uint16_t onColor;
|
||||
uint16_t offColor;
|
||||
};
|
||||
|
||||
// Complete theme definition.
|
||||
struct TFTThemeDef {
|
||||
uint32_t id; // ThemeID constant - in-code identifier for this theme.
|
||||
const char *name; // Human-readable label shown in the theme picker.
|
||||
uint32_t uniqueIdentifier; // Stable persisted value copied into uiconfig.screen_rgb_color.
|
||||
// Never reuse or renumber - see file-level notes above.
|
||||
TFTThemeRoleColor roles[static_cast<size_t>(TFTColorRole::Count)];
|
||||
uint16_t batteryFillGood;
|
||||
uint16_t batteryFillMedium;
|
||||
uint16_t batteryFillBad;
|
||||
bool fullFrameInvert; // Apply full-frame FrameMono inversion (ST7789 light themes)
|
||||
bool visible; // Show in the theme picker menu. Hidden themes still apply
|
||||
// correctly if their uniqueIdentifier is persisted (dev/legacy themes).
|
||||
};
|
||||
|
||||
// Count of themes whose .visible flag is true. Use this when building menus.
|
||||
size_t getVisibleThemeCount();
|
||||
|
||||
// Access the Nth visible theme (0 .. getVisibleThemeCount()-1). Hidden themes
|
||||
// are skipped, preserving kThemes[] order among the visible entries.
|
||||
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex);
|
||||
|
||||
// Return the theme that matches uiconfig.screen_rgb_color (falls back to Dark).
|
||||
const TFTThemeDef &getActiveTheme();
|
||||
|
||||
// Return the visible-theme index for the currently active theme, or SIZE_MAX
|
||||
// if the active theme is hidden (so menus can show "no selection").
|
||||
size_t getActiveVisibleThemeIndex();
|
||||
|
||||
// Convenience accessors - safe to call even when coloring is compiled out.
|
||||
uint16_t getThemeHeaderBg();
|
||||
uint16_t getThemeHeaderText();
|
||||
uint16_t getThemeHeaderStatus();
|
||||
uint16_t getThemeBodyBg();
|
||||
uint16_t getThemeBodyFg();
|
||||
bool isThemeFullFrameInvert();
|
||||
uint16_t getThemeBatteryFillColor(int batteryPercent);
|
||||
|
||||
// Reinitialise default roleColors from the active theme. Call after a
|
||||
// theme change so that any role registered without a prior setTFTColorRole()
|
||||
// picks up theme-appropriate defaults.
|
||||
void loadThemeDefaults();
|
||||
|
||||
} // namespace graphics
|
||||
+160
-42
@@ -16,12 +16,6 @@
|
||||
extern SX1509 gpioExtender;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_MESH_OVERRIDE
|
||||
uint16_t TFT_MESH = TFT_MESH_OVERRIDE;
|
||||
#else
|
||||
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
|
||||
#endif
|
||||
|
||||
#if defined(ST7735S)
|
||||
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
|
||||
|
||||
@@ -428,7 +422,7 @@ static LGFX *tft = nullptr;
|
||||
|
||||
#elif defined(ST7789_CS)
|
||||
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
|
||||
#ifdef HELTEC_V4_TFT
|
||||
#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)
|
||||
#include "chsc6x.h"
|
||||
#include "lgfx/v1/Touch.hpp"
|
||||
namespace lgfx
|
||||
@@ -450,7 +444,11 @@ class TOUCH_CHSC6X : public ITouch
|
||||
bool init(void) override
|
||||
{
|
||||
if (chsc6xTouch == nullptr) {
|
||||
#if (TOUCH_I2C_PORT == 1)
|
||||
chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN);
|
||||
#else
|
||||
chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN);
|
||||
#endif
|
||||
}
|
||||
chsc6xTouch->chsc6x_init();
|
||||
return true;
|
||||
@@ -487,7 +485,7 @@ class LGFX : public lgfx::LGFX_Device
|
||||
#if HAS_TOUCHSCREEN
|
||||
#if defined(T_WATCH_S3) || defined(ELECROW)
|
||||
lgfx::Touch_FT5x06 _touch_instance;
|
||||
#elif defined(HELTEC_V4_TFT)
|
||||
#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)
|
||||
lgfx::TOUCH_CHSC6X _touch_instance;
|
||||
#else
|
||||
lgfx::Touch_GT911 _touch_instance;
|
||||
@@ -506,7 +504,11 @@ class LGFX : public lgfx::LGFX_Device
|
||||
cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing
|
||||
// 80MHz by an integer)
|
||||
cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving
|
||||
cfg.spi_3wire = false;
|
||||
#ifdef SPI_3_WIRE
|
||||
cfg.spi_3wire = SPI_3_WIRE;
|
||||
#else
|
||||
cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin
|
||||
#endif
|
||||
cfg.use_lock = true; // Set to true to use transaction locking
|
||||
cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch /
|
||||
// SPI_DMA_CH_AUTO=auto setting)
|
||||
@@ -556,8 +558,11 @@ class LGFX : public lgfx::LGFX_Device
|
||||
cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped
|
||||
cfg.dlen_16bit =
|
||||
false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI
|
||||
#if defined(HAS_SDCARD)
|
||||
cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.)
|
||||
|
||||
#else
|
||||
cfg.bus_shared = false;
|
||||
#endif
|
||||
// Set the following only when the display is shifted with a driver with a variable number of pixels, such as the
|
||||
// ST7735 or ILI9163.
|
||||
// cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC
|
||||
@@ -1140,7 +1145,9 @@ static LGFX *tft = nullptr;
|
||||
#endif
|
||||
|
||||
#include "SPILock.h"
|
||||
#include "TFTColorRegions.h"
|
||||
#include "TFTDisplay.h"
|
||||
#include "TFTPalette.h"
|
||||
#include <SPI.h>
|
||||
|
||||
#ifdef UNPHONE
|
||||
@@ -1150,6 +1157,25 @@ extern unPhone unphone;
|
||||
|
||||
GpioPin *TFTDisplay::backlightEnable = NULL;
|
||||
|
||||
namespace
|
||||
{
|
||||
static constexpr uint8_t kFullRepaintChunkRows = 8;
|
||||
|
||||
static inline uint16_t getThemeDefaultOnColor()
|
||||
{
|
||||
return graphics::TFTPalette::White;
|
||||
}
|
||||
|
||||
static inline uint16_t getThemeDefaultOffColor()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return graphics::getThemeBodyBg();
|
||||
#else
|
||||
return TFT_BLACK;
|
||||
#endif
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
|
||||
{
|
||||
LOG_DEBUG("TFTDisplay!");
|
||||
@@ -1189,14 +1215,15 @@ TFTDisplay::~TFTDisplay()
|
||||
free(linePixelBuffer);
|
||||
linePixelBuffer = nullptr;
|
||||
}
|
||||
if (repaintChunkBuffer != nullptr) {
|
||||
free(repaintChunkBuffer);
|
||||
repaintChunkBuffer = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the buffer to the display memory
|
||||
void TFTDisplay::display(bool fromBlank)
|
||||
{
|
||||
if (fromBlank)
|
||||
tft->fillScreen(TFT_BLACK);
|
||||
|
||||
concurrency::LockGuard g(spiLock);
|
||||
|
||||
uint32_t x, y;
|
||||
@@ -1205,12 +1232,70 @@ void TFTDisplay::display(bool fromBlank)
|
||||
uint32_t x_FirstPixelUpdate;
|
||||
uint32_t x_LastPixelUpdate;
|
||||
bool isset, dblbuf_isset;
|
||||
uint16_t colorTftMesh, colorTftBlack;
|
||||
uint16_t colorTftWhite, colorTftBlack;
|
||||
bool somethingChanged = false;
|
||||
|
||||
// Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step
|
||||
colorTftMesh = __builtin_bswap16(TFT_MESH);
|
||||
colorTftBlack = __builtin_bswap16(TFT_BLACK);
|
||||
// Theme defaults for non-role pixels.
|
||||
const uint16_t defaultOnColor = getThemeDefaultOnColor();
|
||||
const uint16_t defaultOffColor = getThemeDefaultOffColor();
|
||||
static uint16_t lastDefaultOnColor = 0;
|
||||
static uint16_t lastDefaultOffColor = 0;
|
||||
static bool haveLastDefaults = false;
|
||||
const bool themeDefaultsChanged =
|
||||
!haveLastDefaults || (defaultOnColor != lastDefaultOnColor) || (defaultOffColor != lastDefaultOffColor);
|
||||
const bool forceFullRepaint = fromBlank || themeDefaultsChanged;
|
||||
|
||||
// If theme defaults changed, reset panel background immediately so stale pixels don't linger.
|
||||
if (forceFullRepaint) {
|
||||
tft->fillScreen(defaultOffColor);
|
||||
}
|
||||
|
||||
colorTftWhite = (defaultOnColor >> 8) | ((defaultOnColor & 0xFF) << 8);
|
||||
colorTftBlack = (defaultOffColor >> 8) | ((defaultOffColor & 0xFF) << 8);
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
static uint32_t lastColorFrameSignature = 0;
|
||||
const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
|
||||
const uint32_t colorFrameSignature = graphics::getTFTColorFrameSignature();
|
||||
const bool forceFullColorRepaint = forceFullRepaint || (colorFrameSignature != lastColorFrameSignature);
|
||||
|
||||
// When region roles/layout changed, color can differ even with identical monochrome glyph bits.
|
||||
// Repaint full frame only for those frames, then return to diff-based updates.
|
||||
if (forceFullColorRepaint) {
|
||||
for (uint32_t yStart = 0; yStart < displayHeight; yStart += kFullRepaintChunkRows) {
|
||||
const uint32_t rowsThisChunk = min<uint32_t>(kFullRepaintChunkRows, displayHeight - yStart);
|
||||
for (uint32_t row = 0; row < rowsThisChunk; row++) {
|
||||
y = yStart + row;
|
||||
y_byteIndex = (y / 8) * displayWidth;
|
||||
y_byteMask = (1 << (y & 7));
|
||||
|
||||
uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth);
|
||||
for (x = 0; x < displayWidth; x++) {
|
||||
isset = (buffer[x + y_byteIndex] & y_byteMask) != 0;
|
||||
if (hasColorRegions) {
|
||||
chunkRow[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
|
||||
colorTftWhite, colorTftBlack);
|
||||
} else {
|
||||
chunkRow[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
}
|
||||
}
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk);
|
||||
#else
|
||||
tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer);
|
||||
#endif
|
||||
}
|
||||
|
||||
memcpy(buffer_back, buffer, displayBufferSize);
|
||||
lastColorFrameSignature = colorFrameSignature;
|
||||
haveLastDefaults = true;
|
||||
lastDefaultOnColor = defaultOnColor;
|
||||
lastDefaultOffColor = defaultOffColor;
|
||||
graphics::clearTFTColorRegions();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
y = 0;
|
||||
while (y < displayHeight) {
|
||||
@@ -1219,7 +1304,7 @@ void TFTDisplay::display(bool fromBlank)
|
||||
|
||||
// Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas.
|
||||
if (y_byteMask == 1) {
|
||||
if (!fromBlank) {
|
||||
if (!forceFullRepaint) {
|
||||
for (x = 0; x < displayWidth; x++) {
|
||||
if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex])
|
||||
break;
|
||||
@@ -1237,13 +1322,14 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating
|
||||
for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) {
|
||||
isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
|
||||
// Step 2: Scan this row for changed span (first and last changed pixel).
|
||||
uint32_t x_FirstChanged = 0;
|
||||
for (x_FirstChanged = 0; x_FirstChanged < displayWidth; x_FirstChanged++) {
|
||||
isset = buffer[x_FirstChanged + y_byteIndex] & y_byteMask;
|
||||
|
||||
if (!fromBlank) {
|
||||
if (!forceFullRepaint) {
|
||||
// get src pixel in the page based ordering the OLED lib uses
|
||||
dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
|
||||
dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask;
|
||||
if (isset != dblbuf_isset) {
|
||||
break;
|
||||
}
|
||||
@@ -1253,26 +1339,42 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
|
||||
// Did we find a pixel that needs updating on this row?
|
||||
if (x_FirstPixelUpdate < displayWidth) {
|
||||
|
||||
// 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;
|
||||
|
||||
if (!fromBlank) {
|
||||
dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask;
|
||||
if (x_FirstChanged < displayWidth) {
|
||||
uint32_t x_LastChanged = displayWidth - 1;
|
||||
while (x_LastChanged > x_FirstChanged) {
|
||||
isset = buffer[x_LastChanged + y_byteIndex] & y_byteMask;
|
||||
if (!forceFullRepaint) {
|
||||
dblbuf_isset = buffer_back[x_LastChanged + y_byteIndex] & y_byteMask;
|
||||
if (isset != dblbuf_isset) {
|
||||
x_LastPixelUpdate = x;
|
||||
break;
|
||||
}
|
||||
} else if (isset) {
|
||||
x_LastPixelUpdate = x;
|
||||
break;
|
||||
}
|
||||
x_LastChanged--;
|
||||
}
|
||||
|
||||
// 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 = x_FirstChanged & ~1U;
|
||||
x_LastPixelUpdate = x_LastChanged | 1U;
|
||||
if (x_LastPixelUpdate >= displayWidth) {
|
||||
x_LastPixelUpdate = displayWidth - 1;
|
||||
}
|
||||
|
||||
// Step 3: Copy only the changed span into the pixel line buffer.
|
||||
for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
|
||||
isset = buffer[x + y_byteIndex] & y_byteMask;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (hasColorRegions) {
|
||||
linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
|
||||
colorTftWhite, colorTftBlack);
|
||||
} else {
|
||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
}
|
||||
#else
|
||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
#endif
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
|
||||
@@ -1280,8 +1382,8 @@ void TFTDisplay::display(bool fromBlank)
|
||||
#else
|
||||
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
|
||||
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
|
||||
tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||
tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||
#endif
|
||||
somethingChanged = true;
|
||||
}
|
||||
@@ -1290,6 +1392,14 @@ void TFTDisplay::display(bool fromBlank)
|
||||
// Copy the Buffer to the Back Buffer
|
||||
if (somethingChanged)
|
||||
memcpy(buffer_back, buffer, displayBufferSize);
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
lastColorFrameSignature = colorFrameSignature;
|
||||
#endif
|
||||
haveLastDefaults = true;
|
||||
lastDefaultOnColor = defaultOnColor;
|
||||
lastDefaultOffColor = defaultOffColor;
|
||||
graphics::clearTFTColorRegions();
|
||||
}
|
||||
|
||||
void TFTDisplay::sdlLoop()
|
||||
@@ -1503,7 +1613,7 @@ bool TFTDisplay::connect()
|
||||
#else
|
||||
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
|
||||
#endif
|
||||
tft->fillScreen(TFT_BLACK);
|
||||
tft->fillScreen(getThemeDefaultOffColor());
|
||||
|
||||
if (this->linePixelBuffer == NULL) {
|
||||
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
|
||||
@@ -1513,6 +1623,14 @@ bool TFTDisplay::connect()
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this->repaintChunkBuffer == NULL) {
|
||||
this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows);
|
||||
|
||||
if (!this->repaintChunkBuffer) {
|
||||
LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,4 +63,5 @@ class TFTDisplay : public OLEDDisplay
|
||||
virtual bool connect() override;
|
||||
|
||||
uint16_t *linePixelBuffer = nullptr;
|
||||
};
|
||||
uint16_t *repaintChunkBuffer = nullptr;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user