mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-14 15:31:31 +10:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6a74f06c | ||
|
|
5f734dabf9 | ||
|
|
039ad42758 | ||
|
|
593909c26b | ||
|
|
29a61dc75c | ||
|
|
eead467ce6 | ||
|
|
f3ae02c425 | ||
|
|
d9cb74e4dd | ||
|
|
7ff6641f97 | ||
|
|
f7548e7c25 | ||
|
|
b960121464 | ||
|
|
b074645586 | ||
|
|
811dd427dd | ||
|
|
1c06b702dc | ||
|
|
59b4993861 | ||
|
|
4446b0f1a2 | ||
|
|
64fd61706d | ||
|
|
da61a0db7d | ||
|
|
8e99ffbe7e | ||
|
|
8877608858 | ||
|
|
a23f923e64 | ||
|
|
0522039830 | ||
|
|
dfcb685963 | ||
|
|
9bc25b34fd | ||
|
|
33319aa4e2 | ||
|
|
d79e62fd2a | ||
|
|
f6a954b97e | ||
|
|
94bb21ecc7 | ||
|
|
1bcabb893b | ||
|
|
10a7f1042b | ||
|
|
fefd424901 | ||
|
|
b4234b7f11 | ||
|
|
f63716c322 | ||
|
|
5512185cfe | ||
|
|
a8a785bbb7 | ||
|
|
0f854862e7 |
@@ -49,11 +49,17 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
|||||||
- Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh)
|
- 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.
|
- Do the primary channel NAMES match? Mismatch = different PSK = no decode.
|
||||||
|
|
||||||
7. **Suggest next actions only for specific, recognisable failure modes**:
|
7. **Recorder slice (cheap, always available).** The mcp-server runs an autouse log recorder that's been collecting from every connected device. Pull two short slices to surface anything weird that's already happened:
|
||||||
|
- `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` — recent firmware errors. If empty, say "no recent errors"; don't manufacture concern.
|
||||||
|
- `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` — heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap.
|
||||||
|
- If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet — enable `set_debug_log_api(True)` to populate" and skip this step gracefully.
|
||||||
|
|
||||||
|
8. **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."
|
- 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`."
|
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
|
||||||
- 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)`.
|
- 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`.
|
- CP2102-wedged-driver on macOS → see the note in `run-tests.sh`.
|
||||||
|
- Heap slope strongly negative → "run `/leakhunt window=6h` for a full timeline + classification."
|
||||||
|
|
||||||
## What NOT to do
|
## What NOT to do
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
description: Hunt for memory leaks (and other slow degradations) by reading the persistent recorder's heap timeline + log slice over a window
|
||||||
|
argument-hint: [window=1h] [field=free_heap] [variant=local]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD029 -->
|
||||||
|
|
||||||
|
# `/leakhunt` — read the recorder, classify a memory leak
|
||||||
|
|
||||||
|
Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags — the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`.
|
||||||
|
|
||||||
|
## Two signal paths — pick the right one
|
||||||
|
|
||||||
|
| Path | Build flag | Cadence | Per-thread attribution | Cost |
|
||||||
|
| --------------------- | ---------------- | -------------- | ---------------------- | ------------------------- |
|
||||||
|
| LocalStats packet | (default) | ~60 s | No | Free — always on |
|
||||||
|
| `[heap N]` log prefix | `-DDEBUG_HEAP=1` | every log line | Yes (Thread X leaked) | Bigger flash + log volume |
|
||||||
|
|
||||||
|
Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** — when the slope is real and you need to know which thread is leaking.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
1. **Parse `$ARGUMENTS`**: optional `window` (default `1h`, accepts `30m`/`6h`/`-3d`/etc.), optional `field` (default `free_heap`; alternates: `total_heap`, `battery_level`, anything in the LocalStats variant), optional `variant` (default `local`; alternates: `device`, `environment`, `power`, `airQuality`, `health`).
|
||||||
|
|
||||||
|
2. **Verify the recorder is alive** — call `mcp__meshtastic__recorder_status`. Check:
|
||||||
|
- `running == True`
|
||||||
|
- `files.telemetry.lines > 0` (at least one telemetry packet recorded — if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval)
|
||||||
|
- `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent — log that, not "leak detected")
|
||||||
|
|
||||||
|
3. **Detect whether DEBUG_HEAP is active** — `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path.
|
||||||
|
|
||||||
|
4. **Pull the timeline** — `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read:
|
||||||
|
- `samples` — how many raw points contributed
|
||||||
|
- `min`, `max` — total swing
|
||||||
|
- `slope_per_min` — units per minute (linear regression over the whole window)
|
||||||
|
|
||||||
|
5. **Pull the log context for the same window** — `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM).
|
||||||
|
|
||||||
|
6. **Pull marker events** so we know if the operator labeled phases — `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot.
|
||||||
|
|
||||||
|
6a. **(DEBUG_HEAP only) Per-thread attribution** — `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too — a thread with 50× small leaks is different from 1× big leak.
|
||||||
|
|
||||||
|
7. **Classify** based on what the data says, NOT on what you wish it said. Use these rules in order:
|
||||||
|
- **Insufficient data** (< 5 samples): say so. Suggest a longer window or longer wait. Stop.
|
||||||
|
- **Reboot mid-window**: if any `connection_lost` event is present AND `free_heap` jumped UP at that timestamp, the device rebooted. Note it; pre-reboot trend may be a leak but you only have part of the curve.
|
||||||
|
- **OOM-imminent**: any `Alloc an err=` or `out of memory` line in the log slice. This trumps everything; flag urgently.
|
||||||
|
- **Slow leak**: `slope_per_min < -50` AND `max - min > 1000` AND no reboot. The heap is monotonically (or near-monotonically) declining. Estimate time-to-zero: `min / -slope_per_min` minutes. Surface it.
|
||||||
|
- **Fragmentation suspect**: `slope_per_min` close to zero (|x| < 50) BUT min trends down across the window AND the log slice shows `Alloc an err` warnings WITHOUT total OOM. Means free total is OK but largest contiguous block is shrinking. Recommend a `DEBUG_HEAP` build to confirm.
|
||||||
|
- **Steady**: |slope_per_min| < 50, no error lines. Heap is fine.
|
||||||
|
- **Recovery curve**: slope is POSITIVE — heap recovered. Either a workload completed or GC fired. Note it; not a leak.
|
||||||
|
|
||||||
|
8. **Report**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/leakhunt window=6h field=free_heap variant=local
|
||||||
|
────────────────────────────────────────────────────
|
||||||
|
recorder : running, telem last_ts 8s ago
|
||||||
|
build : DEBUG_HEAP=ON (per-line prefix detected)
|
||||||
|
samples : 14,200 over 6h (cadence ~1.5s, log-line synth)
|
||||||
|
free_heap : min 92,344 / max 124,008 / range 31,664
|
||||||
|
slope : -82 bytes/min (negative — heap declining)
|
||||||
|
reboots : none in window
|
||||||
|
OOM events : none
|
||||||
|
error lines : 3× "Alloc an err=ESP_ERR_NO_MEM" at +4h12m, +5h08m, +5h44m
|
||||||
|
thread leaks : (DEBUG_HEAP) MeshPacket -3,124 B over 18 events
|
||||||
|
Router -1,408 B over 4 events
|
||||||
|
others -240 B
|
||||||
|
verdict : SLOW LEAK — primary suspect MeshPacket thread
|
||||||
|
est. time-to-OOM: ~1,127 min (~18.8 h) at current slope
|
||||||
|
evidence : (3 log line citations with uptimes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: **what to do next.**
|
||||||
|
- SLOW LEAK, **DEBUG_HEAP off** → recommend rebuilding with the flag and re-running this skill. Concrete one-liner the operator can copy:
|
||||||
|
```text
|
||||||
|
mcp__meshtastic__build(env="<env>", build_flags={"DEBUG_HEAP": 1})
|
||||||
|
mcp__meshtastic__pio_flash(env="<env>", port="<port>", confirm=True)
|
||||||
|
```
|
||||||
|
After flash, set debug_log_api back on and wait one window; re-run `/leakhunt`.
|
||||||
|
- SLOW LEAK, **DEBUG_HEAP on** → cite the top-leaking thread name from step 6a. Point at the corresponding source file (`grep -rn "ThreadName(\"<name>\")" src/`); the operator decides what to fix.
|
||||||
|
- FRAGMENTATION SUSPECT → propose pre-allocating any per-packet buffers; or rebuilding with `CONFIG_HEAP_TASK_TRACKING=y` on ESP32 to see who's holding the largest blocks.
|
||||||
|
- OOM-IMMINENT → flag for immediate attention; don't wait for the next telemetry interval.
|
||||||
|
- STEADY → say so; stop. Don't invent problems.
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- Don't assume a leak from a single dip. LocalStats fires every ~60 s and the firmware naturally allocates+frees on each broadcast cycle; one packet sees the trough. Look at the slope, not the deltas.
|
||||||
|
- Don't recommend code changes. This skill diagnoses; the operator decides what to fix.
|
||||||
|
- Don't enable `set_debug_log_api` automatically — if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run.
|
||||||
|
- Don't run heavy workloads to "trigger the leak." The recorder is passive; we read what's there.
|
||||||
|
|
||||||
|
## Companion: `mark_event` for stress runs
|
||||||
|
|
||||||
|
If the operator wants to test under stimulus (e.g. blast 50 broadcasts and see what the heap does), they can frame the experiment with markers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mark_event("burst-start")
|
||||||
|
… run the workload …
|
||||||
|
mark_event("burst-end")
|
||||||
|
/leakhunt window=15m
|
||||||
|
```
|
||||||
|
|
||||||
|
The markers land in both `events.jsonl` and `logs.jsonl`, so the report can show "free_heap dipped 8 KB during the burst window, recovered to baseline within 2 LocalStats cycles" → not a leak.
|
||||||
@@ -3,6 +3,8 @@ description: Re-run a specific test N times in isolation to triage flakes, diff
|
|||||||
argument-hint: <test-node-id> [count=5]
|
argument-hint: <test-node-id> [count=5]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD029 -->
|
||||||
|
|
||||||
# `/repro` — flakiness triage for one test
|
# `/repro` — flakiness triage for one test
|
||||||
|
|
||||||
Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y."
|
Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y."
|
||||||
@@ -40,6 +42,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
|
|||||||
|
|
||||||
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps.
|
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps.
|
||||||
|
|
||||||
|
5a. **Archive recorder slices per attempt** (no extra device interaction; the recorder runs autouse). Right after each attempt finishes, capture its `(start_ts, end_ts)` and call `mcp__meshtastic__recorder_export(start=<start>, end=<end>, dest_dir="mcp-server/tests/repro_artifacts/<safe-test-id>/attempt_<n>/")`. This drops a `logs.jsonl`, `telemetry.jsonl`, `packets.jsonl`, and `events.jsonl` snapshot scoped to the attempt window. Use these for cross-attempt diffs in step 5: `jq '.line' logs.jsonl` is faster than re-running the test, and the telemetry slice lets you compare heap behavior across attempts.
|
||||||
|
|
||||||
6. **Classify the flake** into one of:
|
6. **Classify the flake** into one of:
|
||||||
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
|
- **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.
|
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
|
||||||
|
|||||||
@@ -135,6 +135,99 @@ On top of authorization, any remote admin message that **mutates** state (not a
|
|||||||
- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast.
|
- **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.
|
- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo.
|
||||||
|
|
||||||
|
## NodeDB Layout (v25)
|
||||||
|
|
||||||
|
`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now — the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`.
|
||||||
|
|
||||||
|
### Slim `NodeInfoLite`
|
||||||
|
|
||||||
|
`UserLite` is flattened onto `NodeInfoLite` (no nested sub-message); `position` and `device_metrics` are removed entirely (tags reserved). MAC address is dropped. Long names are capped at 25 chars (`max_size:25` in `deviceonly.options`); `hw_model` and `role` are `int_size:8`. Encoded size dropped from ~166 B → ~105 B per node.
|
||||||
|
|
||||||
|
Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** — use the inline helpers in `src/mesh/NodeDB.h`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
nodeInfoLiteHasUser(n) // bit 5 — user fields populated
|
||||||
|
nodeInfoLiteIsFavorite(n) // bit 3
|
||||||
|
nodeInfoLiteIsIgnored(n) // bit 4
|
||||||
|
nodeInfoLiteIsMuted(n) // bit 1
|
||||||
|
nodeInfoLiteIsLicensed(n) // bit 6 — Ham mode peer
|
||||||
|
nodeInfoLiteIsKeyManuallyVerified(n) // bit 0
|
||||||
|
nodeInfoLiteHasIsUnmessagable(n) // bit 8 — "is_unmessagable was sent"
|
||||||
|
nodeInfoLiteIsUnmessagable(n) // bit 7
|
||||||
|
// via_mqtt is bit 2 (mask exposed; predicate uses the mask directly)
|
||||||
|
|
||||||
|
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); // setter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Satellite stores
|
||||||
|
|
||||||
|
Four `std::unordered_map<NodeNum, …>` members on `NodeDB`, each gated by its own build flag:
|
||||||
|
|
||||||
|
| Map | Value type | Build flag |
|
||||||
|
| ----------------- | ------------------------------- | ---------------------------------- |
|
||||||
|
| `nodePositions` | `meshtastic_PositionLite` | `MESHTASTIC_EXCLUDE_POSITIONDB` |
|
||||||
|
| `nodeTelemetry` | `meshtastic_DeviceMetrics` | `MESHTASTIC_EXCLUDE_TELEMETRYDB` |
|
||||||
|
| `nodeEnvironment` | `meshtastic_EnvironmentMetrics` | `MESHTASTIC_EXCLUDE_ENVIRONMENTDB` |
|
||||||
|
| `nodeStatus` | `meshtastic_StatusMessage` | `MESHTASTIC_EXCLUDE_STATUSDB` |
|
||||||
|
|
||||||
|
Defaults are ON (i.e., maps **excluded**) for STM32WL only — see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`.
|
||||||
|
|
||||||
|
All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** — concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception.
|
||||||
|
|
||||||
|
### Accessor convention
|
||||||
|
|
||||||
|
**Never hand out pointers into the maps.** Use the copy-out accessors on `NodeDB`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool copyNodePosition(NodeNum, meshtastic_PositionLite &out) const;
|
||||||
|
bool copyNodeTelemetry(NodeNum, meshtastic_DeviceMetrics &out) const;
|
||||||
|
bool copyNodeEnvironment(NodeNum, meshtastic_EnvironmentMetrics &out) const;
|
||||||
|
bool copyNodeStatus(NodeNum, meshtastic_StatusMessage &out) const;
|
||||||
|
```
|
||||||
|
|
||||||
|
Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate — pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these.
|
||||||
|
|
||||||
|
Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) — these own the lock and the eviction hooks.
|
||||||
|
|
||||||
|
### Eviction
|
||||||
|
|
||||||
|
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly.
|
||||||
|
|
||||||
|
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
|
||||||
|
|
||||||
|
There is no capability flag and no special "gradient" nonce. The **default** sync flow is:
|
||||||
|
|
||||||
|
1. Config / module-config / channel / metadata segments (same as before).
|
||||||
|
2. `STATE_SEND_OWN_NODEINFO` — **our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`.
|
||||||
|
3. `STATE_SEND_OTHER_NODEINFOS` — every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`.
|
||||||
|
4. `STATE_SEND_FILEMANIFEST` → `STATE_SEND_COMPLETE_ID` — the phone sees `config_complete_id` and treats sync as done.
|
||||||
|
5. `STATE_SEND_PACKETS` — live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling — any code that already updates UI on `POSITION_APP` etc. works.
|
||||||
|
|
||||||
|
`PhoneAPI::sendConfigComplete()` arms `replayPhase = REPLAY_PHASE_POSITIONS` for default/full sync and `SPECIAL_NONCE_ONLY_NODES`, while `SPECIAL_NONCE_ONLY_CONFIG` skips replay. The drain runs inside `STATE_SEND_PACKETS` via `popReplayPacket()`, lower priority than live traffic. When all four phases drain, `replayPhase` flips back to `REPLAY_PHASE_IDLE` and the snapshot vectors get `shrink_to_fit`ed.
|
||||||
|
|
||||||
|
STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets — `popReplayPacket` advances through each phase in microseconds without emitting anything.
|
||||||
|
|
||||||
|
Special nonces that still mean something:
|
||||||
|
|
||||||
|
- `SPECIAL_NONCE_ONLY_CONFIG` (69420) — skip node sync entirely, just config.
|
||||||
|
- `SPECIAL_NONCE_ONLY_NODES` (69421) — skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain.
|
||||||
|
|
||||||
|
There are no other reserved nonces; everything else is a fresh random `want_config_id` from the client.
|
||||||
|
|
||||||
|
### v24 → v25 migration
|
||||||
|
|
||||||
|
The legacy migration code lives in **`src/mesh/NodeDBLegacyMigration.cpp`**, not in `NodeDB.cpp`. It owns the `meshtastic_NodeDatabase_Legacy` callback and `NodeDB::migrateLegacyNodeDatabase()`. The legacy proto descriptor is `protobufs/meshtastic/deviceonly_legacy.proto` (only included by the migration TU). The boot path peeks the file's leading version tag, runs the migration if `version < 25`, then re-saves in v25 layout. The legacy descriptor is scheduled for removal once `DEVICESTATE_MIN_VER` is bumped.
|
||||||
|
|
||||||
|
### Read-site rules of thumb
|
||||||
|
|
||||||
|
- Never `node->position.X` / `node->device_metrics.X` — those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`.
|
||||||
|
- Never `node->user.long_name` — `long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`.
|
||||||
|
- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` — use the bitfield helpers.
|
||||||
|
- Never assume `nodeDB->getMeshNode(num)->position.time` — call `copyNodePosition` and check the return.
|
||||||
|
- Don't lock `satelliteMutex` yourself in renderer code; the copy-out accessors already do.
|
||||||
|
|
||||||
|
Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) — bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -196,6 +289,8 @@ firmware/
|
|||||||
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
||||||
- Use `assert()` for invariants that should never fail
|
- Use `assert()` for invariants that should never fail
|
||||||
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
|
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
|
||||||
|
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||||
|
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,9 @@ CMakeLists.txt
|
|||||||
.python3
|
.python3
|
||||||
.claude/scheduled_tasks.lock
|
.claude/scheduled_tasks.lock
|
||||||
userPrefs.jsonc.mcp-session-bak
|
userPrefs.jsonc.mcp-session-bak
|
||||||
|
|
||||||
|
# Fake-NodeDB fixture pipeline (bin/regen-fake-nodedbs.sh)
|
||||||
|
# JSONL seeds are committed (test/fixtures/nodedb/seed_v25_*.jsonl);
|
||||||
|
# compiled .proto outputs are ephemeral build artifacts.
|
||||||
|
build/fixtures/
|
||||||
|
bin/_generated/
|
||||||
|
|||||||
+9
-2
@@ -4,11 +4,11 @@ cli:
|
|||||||
plugins:
|
plugins:
|
||||||
sources:
|
sources:
|
||||||
- id: trunk
|
- id: trunk
|
||||||
ref: v1.8.0
|
ref: v1.9.0
|
||||||
uri: https://github.com/trunk-io/plugins
|
uri: https://github.com/trunk-io/plugins
|
||||||
lint:
|
lint:
|
||||||
enabled:
|
enabled:
|
||||||
- checkov@3.2.526
|
- checkov@3.2.528
|
||||||
- renovate@43.150.0
|
- renovate@43.150.0
|
||||||
- prettier@3.8.3
|
- prettier@3.8.3
|
||||||
- trufflehog@3.95.2
|
- trufflehog@3.95.2
|
||||||
@@ -34,6 +34,13 @@ lint:
|
|||||||
- linters: [ALL]
|
- linters: [ALL]
|
||||||
paths:
|
paths:
|
||||||
- bin/**
|
- bin/**
|
||||||
|
# Fake-NodeDB fixture JSONL files contain deterministic synthetic
|
||||||
|
# public_key_hex (64-char hex) values that gitleaks misidentifies as
|
||||||
|
# generic-api-key. These are not secrets — they're test fixtures
|
||||||
|
# produced by bin/gen-fake-nodedb-seed.py with a fixed RNG seed.
|
||||||
|
- linters: [gitleaks]
|
||||||
|
paths:
|
||||||
|
- test/fixtures/nodedb/seed_v25_*.jsonl
|
||||||
runtimes:
|
runtimes:
|
||||||
enabled:
|
enabled:
|
||||||
- python@3.14.4
|
- python@3.14.4
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
|||||||
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
||||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
||||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||||
|
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||||
|
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||||
|
|
||||||
## Typical agent workflows
|
## Typical agent workflows
|
||||||
|
|
||||||
|
|||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post-process protoc-generated Python files to live under a local namespace.
|
||||||
|
|
||||||
|
Called by bin/regen-py-protos.sh. Walks the generated *_pb2.py files in the
|
||||||
|
target directory and rewrites every `meshtastic` reference (imports, dotted
|
||||||
|
attribute access) to use the new namespace (e.g., `meshtastic_v25`).
|
||||||
|
|
||||||
|
Why: the .proto files declare `package meshtastic;`, so protoc emits
|
||||||
|
`from meshtastic import mesh_pb2 as ...` lines. That would shadow the PyPI
|
||||||
|
`meshtastic` package which other parts of the mcp-server depend on. Renaming
|
||||||
|
to a local namespace keeps both available.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
_rewrite_proto_namespace.py <generated_dir> <new_namespace>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(dir_path: pathlib.Path, new_ns: str) -> int:
|
||||||
|
# Standard protoc import forms:
|
||||||
|
# from meshtastic.X_pb2 import ... (rare, for direct symbol pulls)
|
||||||
|
# from meshtastic import X_pb2 as ... (common, the cross-file ref)
|
||||||
|
# import meshtastic.X_pb2 (also possible)
|
||||||
|
pattern_dotted_from = re.compile(r"^from meshtastic\.", re.MULTILINE)
|
||||||
|
pattern_bare_from = re.compile(r"^from meshtastic import ", re.MULTILINE)
|
||||||
|
pattern_dotted_import = re.compile(r"^import meshtastic\.", re.MULTILINE)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for p in dir_path.glob("*.py"):
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
new = pattern_dotted_from.sub(f"from {new_ns}.", text)
|
||||||
|
new = pattern_bare_from.sub(f"from {new_ns} import ", new)
|
||||||
|
new = pattern_dotted_import.sub(f"import {new_ns}.", new)
|
||||||
|
# NOTE: we deliberately leave `meshtastic/X.proto` source-filename
|
||||||
|
# references inside descriptor strings alone. The descriptor pool is
|
||||||
|
# keyed by source filename (independent of Python package layout), so
|
||||||
|
# those don't collide with the PyPI package's descriptors.
|
||||||
|
if new != text:
|
||||||
|
p.write_text(new, encoding="utf-8")
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) != 2:
|
||||||
|
print("usage: _rewrite_proto_namespace.py <generated_dir> <new_namespace>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
dir_path = pathlib.Path(argv[0])
|
||||||
|
new_ns = argv[1]
|
||||||
|
if not dir_path.is_dir():
|
||||||
|
print(f"directory not found: {dir_path}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
n = rewrite(dir_path, new_ns)
|
||||||
|
print(f"rewrote {n} file(s) in {dir_path} → namespace {new_ns}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
Executable
+439
@@ -0,0 +1,439 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deterministic seed-data generator for the fake NodeDB fixture pipeline.
|
||||||
|
|
||||||
|
Writes a JSONL file describing N fake-but-realistic Meshtastic peers.
|
||||||
|
The output is hand-editable and committed; a sibling compile step
|
||||||
|
(bin/seed-json-to-proto.py) turns it into a binary `meshtastic_NodeDatabase`
|
||||||
|
v25 protobuf with fresh "now-relative" timestamps.
|
||||||
|
|
||||||
|
Determinism contract:
|
||||||
|
Same --seed -> byte-identical JSONL output, regardless of wall clock.
|
||||||
|
All timestamps are stored as `*_offset_sec` (seconds before "now"); the
|
||||||
|
compile step resolves them to absolute epochs at compile time.
|
||||||
|
|
||||||
|
Structural fields covered:
|
||||||
|
* NodeInfoLite header: num, long_name, short_name, hw_model, role,
|
||||||
|
public_key, snr, channel, hops_away, next_hop, bitfield flags
|
||||||
|
* PositionLite: lat/long Gaussian around --centroid, altitude, source
|
||||||
|
* DeviceMetrics: battery/voltage/util/uptime
|
||||||
|
* EnvironmentMetrics: temp/humidity/pressure/iaq
|
||||||
|
* StatusMessage: error_code (usually zero)
|
||||||
|
|
||||||
|
Active-board allow-list:
|
||||||
|
hw_model values are restricted to the intersection of
|
||||||
|
(a) variants with `custom_meshtastic_support_level = 1` in
|
||||||
|
variants/*/*/platformio.ini, AND
|
||||||
|
(b) values present in the `HardwareModel` enum in mesh.proto.
|
||||||
|
See HW_MODEL_WEIGHTS below. Deprecated boards (legacy TLORA / Heltec V1-2 /
|
||||||
|
classic TBEAM / TBEAM_V0P7 / Nano G1 / etc.) and fuzzer-only sentinels
|
||||||
|
(PORTDUINO, ANDROID_SIM, DIY_V1, ...) are excluded.
|
||||||
|
|
||||||
|
Active-role allow-list:
|
||||||
|
Excludes ROUTER_CLIENT (deprecated v2.3.15) and REPEATER (deprecated v2.7.11).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as _dt
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import pathlib
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Active-board allow-list (intersection of tier-1 variants + HardwareModel enum).
|
||||||
|
# Refresh by running:
|
||||||
|
# for f in $(find variants -name 'platformio.ini' | xargs grep -lE 'custom_meshtastic_support_level = 1'); do
|
||||||
|
# grep custom_meshtastic_hw_model_slug $f | awk -F= '{print $2}' | tr -d ' ';
|
||||||
|
# done | sort -u | comm -12 - <(python3 -c "from meshtastic.protobuf.mesh_pb2 import HardwareModel; print('\\n'.join(HardwareModel.keys()))" | sort)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
HW_MODEL_WEIGHTS: dict[str, float] = {
|
||||||
|
"HELTEC_V3": 14.0,
|
||||||
|
"T_DECK": 9.0,
|
||||||
|
"HELTEC_V4": 8.0,
|
||||||
|
"RAK4631": 8.0,
|
||||||
|
"HELTEC_MESH_POCKET": 6.0,
|
||||||
|
"TRACKER_T1000_E": 5.0,
|
||||||
|
"HELTEC_MESH_NODE_T114": 5.0,
|
||||||
|
"T_DECK_PRO": 5.0,
|
||||||
|
"LILYGO_TBEAM_S3_CORE": 4.0,
|
||||||
|
"HELTEC_WIRELESS_PAPER": 4.0,
|
||||||
|
"HELTEC_WSL_V3": 3.0,
|
||||||
|
"T_ECHO": 3.0,
|
||||||
|
"HELTEC_WIRELESS_TRACKER": 3.0,
|
||||||
|
"HELTEC_WIRELESS_TRACKER_V2": 2.0,
|
||||||
|
"HELTEC_VISION_MASTER_E290": 2.0,
|
||||||
|
"HELTEC_MESH_SOLAR": 2.0,
|
||||||
|
"SEEED_WIO_TRACKER_L1": 2.0,
|
||||||
|
"T_LORA_PAGER": 1.5,
|
||||||
|
"HELTEC_VISION_MASTER_E213": 1.5,
|
||||||
|
"T_ECHO_PLUS": 1.0,
|
||||||
|
"MUZI_BASE": 1.0,
|
||||||
|
"WISMESH_TAP_V2": 1.0,
|
||||||
|
"THINKNODE_M2": 1.0,
|
||||||
|
"THINKNODE_M5": 1.0,
|
||||||
|
"TLORA_T3_S3": 1.0,
|
||||||
|
# Long tail (uniform low weight across remaining tier-1 boards):
|
||||||
|
"HELTEC_V4_R8": 0.3,
|
||||||
|
"HELTEC_VISION_MASTER_T190": 0.3,
|
||||||
|
"HELTEC_HT62": 0.3,
|
||||||
|
"HELTEC_MESH_NODE_T096": 0.3,
|
||||||
|
"M5STACK_C6L": 0.3,
|
||||||
|
"MINI_EPAPER_S3": 0.3,
|
||||||
|
"MUZI_R1_NEO": 0.3,
|
||||||
|
"NOMADSTAR_METEOR_PRO": 0.3,
|
||||||
|
"RAK3312": 0.3,
|
||||||
|
"RAK3401": 0.3,
|
||||||
|
"SEEED_SOLAR_NODE": 0.3,
|
||||||
|
"SEEED_WIO_TRACKER_L1_EINK": 0.3,
|
||||||
|
"SENSECAP_INDICATOR": 0.3,
|
||||||
|
"TBEAM_1_WATT": 0.3,
|
||||||
|
"THINKNODE_M1": 0.3,
|
||||||
|
"THINKNODE_M3": 0.3,
|
||||||
|
"THINKNODE_M6": 0.3,
|
||||||
|
"T_ECHO_LITE": 0.3,
|
||||||
|
"WISMESH_TAG": 0.3,
|
||||||
|
"WISMESH_TAP": 0.3,
|
||||||
|
"XIAO_NRF52_KIT": 0.3,
|
||||||
|
"CROWPANEL": 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Non-deprecated roles only.
|
||||||
|
ROLE_WEIGHTS: dict[str, float] = {
|
||||||
|
"CLIENT": 75.0,
|
||||||
|
"CLIENT_MUTE": 5.0,
|
||||||
|
"ROUTER": 7.0,
|
||||||
|
"TRACKER": 3.0,
|
||||||
|
"SENSOR": 2.0,
|
||||||
|
"CLIENT_HIDDEN": 2.0,
|
||||||
|
"ROUTER_LATE": 2.0,
|
||||||
|
"CLIENT_BASE": 2.0,
|
||||||
|
"TAK": 1.0,
|
||||||
|
"TAK_TRACKER": 0.5,
|
||||||
|
"LOST_AND_FOUND": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Name pools — 60 firsts × 60 lasts = 3600 combinations.
|
||||||
|
FIRSTS = [
|
||||||
|
"Quick", "Brave", "Silent", "Wild", "Lone", "Bright", "Red", "Blue",
|
||||||
|
"Green", "Black", "White", "Iron", "Steel", "Copper", "Silver", "Gold",
|
||||||
|
"Stone", "River", "Forest", "Mountain", "Canyon", "Desert", "Storm", "Sky",
|
||||||
|
"Solar", "Lunar", "Dawn", "Dusk", "Misty", "Frosty", "Sunny", "Shady",
|
||||||
|
"Happy", "Sleepy", "Drowsy", "Sneaky", "Sharp", "Smooth", "Rough", "Loud",
|
||||||
|
"Soft", "Slow", "Fast", "Tall", "Short", "Old", "New", "Tiny",
|
||||||
|
"Giant", "Hidden", "Lost", "Found", "Wandering", "Roving", "Drifting", "Floating",
|
||||||
|
"Burning", "Frozen", "Whispering", "Howling",
|
||||||
|
]
|
||||||
|
LASTS = [
|
||||||
|
"Phoenix", "Lion", "Bear", "Wolf", "Hawk", "Eagle", "Fox", "Lynx",
|
||||||
|
"Cougar", "Coyote", "Raven", "Owl", "Crow", "Falcon", "Heron", "Crane",
|
||||||
|
"Otter", "Badger", "Bison", "Elk", "Moose", "Stag", "Doe", "Hare",
|
||||||
|
"Marmot", "Mole", "Beaver", "Squirrel", "Mustang", "Bronco", "Pony", "Colt",
|
||||||
|
"Cobra", "Viper", "Mamba", "Adder", "Gecko", "Iguana", "Tortoise", "Turtle",
|
||||||
|
"Salmon", "Trout", "Bass", "Pike", "Shark", "Whale", "Dolphin", "Seal",
|
||||||
|
"Cactus", "Yucca", "Sage", "Juniper", "Pine", "Cedar", "Aspen", "Oak",
|
||||||
|
"Bluff", "Mesa", "Arroyo", "Ridge",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Brief callsign pool for licensed-looking suffixes.
|
||||||
|
CALLSIGN_PREFIXES = ["KX", "WD", "N5", "KE", "AB", "W5", "K1", "KQ", "AE", "NM"]
|
||||||
|
|
||||||
|
# Only emojis that fit in 4 UTF-8 bytes (no variation selectors). short_name's
|
||||||
|
# nanopb max_size:5 (incl. NUL) limits content to 4 bytes. ❄️ / ☀️ would be
|
||||||
|
# 6 bytes due to U+FE0F variation selector — explicitly excluded.
|
||||||
|
EMOJI_SHORTNAMES = ["🦊", "🐺", "🦅", "🐢", "🌵", "🔥", "🌙",
|
||||||
|
"🌊", "🗻", "🌲", "🦌", "🐝", "🦂", "🦉",
|
||||||
|
"🦇", "🦋"]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
NUM_RESERVED = 4 # firmware reserves 0..3 (per NodeDB constants)
|
||||||
|
NUM_MAX_EXCLUSIVE = 0x80000000 # restrict to positive int32 range for readability
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_choice(rng: random.Random, weights: dict[str, float]) -> str:
|
||||||
|
"""Deterministic weighted pick. Uses sorted keys so dict order is fixed."""
|
||||||
|
keys = sorted(weights.keys())
|
||||||
|
totals = [weights[k] for k in keys]
|
||||||
|
return rng.choices(keys, weights=totals, k=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_long_name(rng: random.Random, is_licensed: bool) -> str:
|
||||||
|
base = f"{rng.choice(FIRSTS)} {rng.choice(LASTS)}"
|
||||||
|
if is_licensed:
|
||||||
|
prefix = rng.choice(CALLSIGN_PREFIXES)
|
||||||
|
# Two trailing alpha chars after the digit; keep within 25 - len(base) - 1
|
||||||
|
suffix = f" {prefix}{rng.randint(0,9)}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}"
|
||||||
|
# nanopb max_size:25 means C string fits 24 bytes + NUL.
|
||||||
|
if len(base) + len(suffix) <= 24:
|
||||||
|
base = base + suffix
|
||||||
|
# Hard cap to 24 chars (nanopb max_size:25 minus NUL).
|
||||||
|
return base[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_short_name(rng: random.Random, long_name: str) -> str:
|
||||||
|
# 10% emoji-only short_name
|
||||||
|
if rng.random() < 0.10:
|
||||||
|
return rng.choice(EMOJI_SHORTNAMES)
|
||||||
|
first_char = long_name[0].upper() if long_name else "X"
|
||||||
|
alphanums = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
return first_char + "".join(rng.choices(alphanums, k=3))
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_hops_away(rng: random.Random) -> int:
|
||||||
|
# Geometric-ish: 0→55%, 1→25%, 2→12%, 3→5%, 4→2%, 5+→1%
|
||||||
|
r = rng.random()
|
||||||
|
if r < 0.55:
|
||||||
|
return 0
|
||||||
|
if r < 0.80:
|
||||||
|
return 1
|
||||||
|
if r < 0.92:
|
||||||
|
return 2
|
||||||
|
if r < 0.97:
|
||||||
|
return 3
|
||||||
|
if r < 0.99:
|
||||||
|
return 4
|
||||||
|
return rng.randint(5, 7)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_position(
|
||||||
|
rng: random.Random,
|
||||||
|
centroid_lat: float,
|
||||||
|
centroid_lon: float,
|
||||||
|
spread_km: float,
|
||||||
|
last_heard_offset_sec: int,
|
||||||
|
) -> dict:
|
||||||
|
# 1 deg ≈ 111 km at the equator; we use this as a flat approximation.
|
||||||
|
lat = centroid_lat + rng.gauss(0.0, spread_km / 111.0)
|
||||||
|
lon = centroid_lon + rng.gauss(0.0, spread_km / 111.0)
|
||||||
|
altitude = max(0, round(rng.gauss(1376.0, 250.0))) # T or C valley floor + relief
|
||||||
|
# Position was reported up to 300s before last_heard.
|
||||||
|
time_offset_sec = last_heard_offset_sec + rng.randint(0, 300)
|
||||||
|
return {
|
||||||
|
"latitude": round(lat, 6),
|
||||||
|
"longitude": round(lon, 6),
|
||||||
|
"altitude": altitude,
|
||||||
|
"time_offset_sec": time_offset_sec,
|
||||||
|
"location_source": "LOC_INTERNAL",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_telemetry(rng: random.Random) -> dict:
|
||||||
|
# 5% plugged-in (battery_level == 101); rest uniform [10..100].
|
||||||
|
if rng.random() < 0.05:
|
||||||
|
battery_level = 101
|
||||||
|
voltage = 4.20
|
||||||
|
else:
|
||||||
|
battery_level = rng.randint(10, 100)
|
||||||
|
voltage = round(3.3 + (battery_level / 100.0) * 0.9, 3)
|
||||||
|
# Beta distributions for low/right-skewed metrics; randomly draw via gammavariate.
|
||||||
|
def _beta(a: float, b: float) -> float:
|
||||||
|
x = rng.gammavariate(a, 1.0)
|
||||||
|
y = rng.gammavariate(b, 1.0)
|
||||||
|
return x / (x + y)
|
||||||
|
channel_utilization = round(_beta(2.0, 15.0) * 100.0, 2)
|
||||||
|
air_util_tx = round(_beta(1.5, 20.0) * 10.0, 3)
|
||||||
|
uptime_seconds = int(rng.expovariate(1.0 / 86400.0))
|
||||||
|
return {
|
||||||
|
"battery_level": battery_level,
|
||||||
|
"voltage": voltage,
|
||||||
|
"channel_utilization": channel_utilization,
|
||||||
|
"air_util_tx": air_util_tx,
|
||||||
|
"uptime_seconds": uptime_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_environment(rng: random.Random) -> dict:
|
||||||
|
return {
|
||||||
|
"temperature": round(rng.gauss(22.0, 8.0), 2),
|
||||||
|
"relative_humidity": round(min(100.0, max(0.0, rng.gauss(55.0, 20.0))), 2),
|
||||||
|
"barometric_pressure": round(rng.gauss(1013.0, 8.0), 2),
|
||||||
|
"iaq": int(min(500, max(0, round(rng.gauss(50.0, 30.0))))),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_status(rng: random.Random) -> dict:
|
||||||
|
# `StatusMessage` (mesh.proto:1445) has a single free-form `string status`.
|
||||||
|
# Most peers report a healthy short status; occasional alert string.
|
||||||
|
healthy = ["OK", "online", "active", "running", "ready", "nominal"]
|
||||||
|
alert = ["low-batt", "no-gps", "weak-signal", "rebooted", "offline-soon"]
|
||||||
|
if rng.random() < 0.92:
|
||||||
|
return {"status": rng.choice(healthy)}
|
||||||
|
return {"status": rng.choice(alert)}
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_node(
|
||||||
|
rng: random.Random,
|
||||||
|
num: int,
|
||||||
|
centroid_lat: float,
|
||||||
|
centroid_lon: float,
|
||||||
|
spread_km: float,
|
||||||
|
coverage: dict[str, float],
|
||||||
|
last_heard_mean_sec: int,
|
||||||
|
last_heard_max_sec: int,
|
||||||
|
) -> dict:
|
||||||
|
is_licensed = rng.random() < 0.05
|
||||||
|
long_name = _gen_long_name(rng, is_licensed)
|
||||||
|
short_name = _gen_short_name(rng, long_name)
|
||||||
|
hw_model = _weighted_choice(rng, HW_MODEL_WEIGHTS)
|
||||||
|
role = _weighted_choice(rng, ROLE_WEIGHTS)
|
||||||
|
has_public_key = rng.random() < 0.92
|
||||||
|
public_key_hex = (
|
||||||
|
"".join(f"{rng.randint(0,255):02x}" for _ in range(32)) if has_public_key else ""
|
||||||
|
)
|
||||||
|
snr = round(max(-20.0, min(12.0, rng.gauss(6.0, 4.0))), 2)
|
||||||
|
channel = 0 if rng.random() < 0.90 else rng.randint(1, 7)
|
||||||
|
hops_away = _gen_hops_away(rng)
|
||||||
|
next_hop = rng.randint(0, 255) if hops_away > 0 else 0
|
||||||
|
last_heard_offset_sec = int(min(rng.expovariate(1.0 / last_heard_mean_sec), last_heard_max_sec))
|
||||||
|
|
||||||
|
bitfield = {
|
||||||
|
"has_user": True,
|
||||||
|
"is_favorite": rng.random() < 0.08,
|
||||||
|
"is_muted": rng.random() < 0.03,
|
||||||
|
"via_mqtt": rng.random() < 0.12,
|
||||||
|
"is_ignored": rng.random() < 0.01,
|
||||||
|
"is_licensed": is_licensed,
|
||||||
|
"has_is_unmessagable": True,
|
||||||
|
"is_unmessagable": rng.random() < 0.02,
|
||||||
|
"is_key_manually_verified": rng.random() < 0.04,
|
||||||
|
}
|
||||||
|
|
||||||
|
node: dict = {
|
||||||
|
"num": f"0x{num:08x}",
|
||||||
|
"long_name": long_name,
|
||||||
|
"short_name": short_name,
|
||||||
|
"hw_model": hw_model,
|
||||||
|
"role": role,
|
||||||
|
"public_key_hex": public_key_hex,
|
||||||
|
"snr": snr,
|
||||||
|
"channel": channel,
|
||||||
|
"hops_away": hops_away,
|
||||||
|
"next_hop": next_hop,
|
||||||
|
"last_heard_offset_sec": last_heard_offset_sec,
|
||||||
|
"bitfield": bitfield,
|
||||||
|
"position": (
|
||||||
|
_gen_position(rng, centroid_lat, centroid_lon, spread_km, last_heard_offset_sec)
|
||||||
|
if rng.random() < coverage["position"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"telemetry": _gen_telemetry(rng) if rng.random() < coverage["telemetry"] else None,
|
||||||
|
"environment": _gen_environment(rng) if rng.random() < coverage["environment"] else None,
|
||||||
|
"status": _gen_status(rng) if rng.random() < coverage["status"] else None,
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_my_node_num(s: str | None) -> int | None:
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
s = s.strip()
|
||||||
|
if s.startswith("0x") or s.startswith("0X"):
|
||||||
|
return int(s, 16)
|
||||||
|
return int(s)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Deterministic JSONL seed for the fake NodeDB fixture.",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
p.add_argument("--count", type=int, required=True, help="Number of fake nodes to emit.")
|
||||||
|
p.add_argument("--seed", type=int, required=True, help="Deterministic seed.")
|
||||||
|
p.add_argument("--out", required=True, help="Output JSONL path.")
|
||||||
|
p.add_argument(
|
||||||
|
"--centroid",
|
||||||
|
default="33.1284,-107.2528",
|
||||||
|
help="LAT,LON centroid (default: Truth or Consequences, NM).",
|
||||||
|
)
|
||||||
|
p.add_argument("--spread-km", type=float, default=60.0, help="Gaussian std-dev in km.")
|
||||||
|
p.add_argument("--position-coverage", type=float, default=0.85)
|
||||||
|
p.add_argument("--telemetry-coverage", type=float, default=0.70)
|
||||||
|
p.add_argument("--environment-coverage", type=float, default=0.25)
|
||||||
|
p.add_argument("--status-coverage", type=float, default=0.40)
|
||||||
|
p.add_argument("--my-node-num", default=None, help="Exclude this NodeNum from generated set (hex or dec).")
|
||||||
|
p.add_argument("--last-heard-mean-sec", type=int, default=3600)
|
||||||
|
p.add_argument("--last-heard-max-sec", type=int, default=7 * 86400)
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
if args.count <= 0:
|
||||||
|
print("--count must be positive", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
centroid_lat, centroid_lon = (float(s) for s in args.centroid.split(","))
|
||||||
|
except ValueError:
|
||||||
|
print(f"--centroid must be LAT,LON; got {args.centroid!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
my_node_num = _parse_my_node_num(args.my_node_num)
|
||||||
|
|
||||||
|
rng = random.Random(args.seed)
|
||||||
|
|
||||||
|
# 1) Generate a unique deterministic set of NodeNums.
|
||||||
|
nums: set[int] = set()
|
||||||
|
while len(nums) < args.count:
|
||||||
|
n = rng.randrange(NUM_RESERVED, NUM_MAX_EXCLUSIVE)
|
||||||
|
if my_node_num is not None and n == my_node_num:
|
||||||
|
continue
|
||||||
|
nums.add(n)
|
||||||
|
ordered_nums = sorted(nums) # sort to fix output order independent of set hash
|
||||||
|
|
||||||
|
# 2) Per-node generation (in num order, single RNG continues).
|
||||||
|
coverage = {
|
||||||
|
"position": args.position_coverage,
|
||||||
|
"telemetry": args.telemetry_coverage,
|
||||||
|
"environment": args.environment_coverage,
|
||||||
|
"status": args.status_coverage,
|
||||||
|
}
|
||||||
|
nodes = [
|
||||||
|
_gen_node(
|
||||||
|
rng,
|
||||||
|
n,
|
||||||
|
centroid_lat,
|
||||||
|
centroid_lon,
|
||||||
|
args.spread_km,
|
||||||
|
coverage,
|
||||||
|
args.last_heard_mean_sec,
|
||||||
|
args.last_heard_max_sec,
|
||||||
|
)
|
||||||
|
for n in ordered_nums
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3) Write JSONL.
|
||||||
|
out_path = pathlib.Path(args.out)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# `generated_at_iso` is informational; it does NOT affect determinism because
|
||||||
|
# we derive it from the seed, not from wall clock. (Same seed -> same string.)
|
||||||
|
generated_at = _dt.datetime.fromtimestamp(args.seed, tz=_dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
meta = {
|
||||||
|
"_meta": {
|
||||||
|
"version": 25,
|
||||||
|
"seed": args.seed,
|
||||||
|
"count": args.count,
|
||||||
|
"centroid": [centroid_lat, centroid_lon],
|
||||||
|
"spread_km": args.spread_km,
|
||||||
|
"generated_at_iso": generated_at,
|
||||||
|
"my_node_num_excluded": (None if my_node_num is None else f"0x{my_node_num:08x}"),
|
||||||
|
"coverage": coverage,
|
||||||
|
"last_heard_mean_sec": args.last_heard_mean_sec,
|
||||||
|
"last_heard_max_sec": args.last_heard_max_sec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with out_path.open("w", encoding="utf-8") as f:
|
||||||
|
# `ensure_ascii=False` so emoji short_names survive. `sort_keys=True` for
|
||||||
|
# determinism (insertion order varies by Python version otherwise).
|
||||||
|
f.write(json.dumps(meta, ensure_ascii=False, sort_keys=True) + "\n")
|
||||||
|
for node in nodes:
|
||||||
|
f.write(json.dumps(node, ensure_ascii=False, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
print(f"wrote {args.count} nodes to {out_path} ({out_path.stat().st_size} bytes)", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
Executable
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerate the fake-NodeDB fixtures: produces 250 / 500 / 1000 / 2000-node
|
||||||
|
# JSONL seed files + their compiled v25 protobufs.
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
# test/fixtures/nodedb/seed_v25_<N>.jsonl — COMMITTED, hand-editable.
|
||||||
|
# build/fixtures/nodedb/nodes_v25_<N>.proto — .gitignored, build artifact.
|
||||||
|
# Drop into /prefs/nodes.proto.
|
||||||
|
#
|
||||||
|
# Daily use: ./bin/regen-fake-nodedbs.sh
|
||||||
|
# - Recompiles protos from committed seeds (fresh wall-clock timestamps).
|
||||||
|
# Intentional seed bump: REGEN_SEEDS=yes ./bin/regen-fake-nodedbs.sh
|
||||||
|
# - Overwrites the committed JSONL files with freshly-seeded data.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# 1) Make sure the Python protobuf bindings exist (in-tree generation; .gitignored).
|
||||||
|
if [[ ! -d bin/_generated/meshtastic ]]; then
|
||||||
|
echo "regenerating Python protobuf bindings (one-time)..."
|
||||||
|
./bin/regen-py-protos.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) Pick a Python interpreter that has the meshtastic deps installed.
|
||||||
|
# Prefer the mcp-server venv (most likely to be set up by the operator).
|
||||||
|
PY="python3"
|
||||||
|
for cand in mcp-server/.venv/bin/python3 .venv/bin/python3; do
|
||||||
|
if [[ -x "$cand" ]]; then
|
||||||
|
PY="$cand"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3) Pinned seeds per size — bump only when you intentionally want different
|
||||||
|
# structural data committed. Parallel arrays so the script works on
|
||||||
|
# macOS bash 3.2 (no `declare -A`).
|
||||||
|
SIZES=(250 500 1000 2000)
|
||||||
|
SEEDS=(20260511 20260512 20260513 20260514)
|
||||||
|
|
||||||
|
REGEN_SEEDS="${REGEN_SEEDS:-no}"
|
||||||
|
|
||||||
|
mkdir -p build/fixtures/nodedb test/fixtures/nodedb
|
||||||
|
|
||||||
|
for i in 0 1 2 3; do
|
||||||
|
n="${SIZES[$i]}"
|
||||||
|
seed="${SEEDS[$i]}"
|
||||||
|
jsonl=$(printf "test/fixtures/nodedb/seed_v25_%04d.jsonl" "$n")
|
||||||
|
proto=$(printf "build/fixtures/nodedb/nodes_v25_%04d.proto" "$n")
|
||||||
|
|
||||||
|
if [[ "$REGEN_SEEDS" == "yes" || ! -f "$jsonl" ]]; then
|
||||||
|
$PY bin/gen-fake-nodedb-seed.py \
|
||||||
|
--count "$n" \
|
||||||
|
--seed "$seed" \
|
||||||
|
--out "$jsonl" \
|
||||||
|
--centroid 33.1284,-107.2528 \
|
||||||
|
--spread-km 60 \
|
||||||
|
--position-coverage 0.85 \
|
||||||
|
--telemetry-coverage 0.70 \
|
||||||
|
--environment-coverage 0.25 \
|
||||||
|
--status-coverage 0.40
|
||||||
|
echo " seed: $jsonl ($(wc -c < "$jsonl") bytes)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
$PY bin/seed-json-to-proto.py --in "$jsonl" --out "$proto"
|
||||||
|
echo " proto: $proto ($(wc -c < "$proto") bytes)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. To load on Portduino native:"
|
||||||
|
echo " cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto"
|
||||||
|
echo ""
|
||||||
|
echo "To push to a hardware device:"
|
||||||
|
echo " Use the mcp-server tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)"
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerate Python protobuf bindings from the in-tree `protobufs/` submodule
|
||||||
|
# into `bin/_generated/`. Called by bin/regen-fake-nodedbs.sh; also useful as
|
||||||
|
# a standalone refresh after any change to a .proto file.
|
||||||
|
#
|
||||||
|
# Output is .gitignored — bindings are a build artifact.
|
||||||
|
#
|
||||||
|
# Namespace rewrite:
|
||||||
|
# The .proto files declare `package meshtastic;`, which makes protoc emit
|
||||||
|
# imports like `from meshtastic import mesh_pb2`. That conflicts with the
|
||||||
|
# PyPI `meshtastic` package (which the mcp-server relies on for its
|
||||||
|
# SerialInterface/BLEInterface transport). We post-process the generated
|
||||||
|
# files to live under `meshtastic_v25` instead — both the directory layout
|
||||||
|
# and all internal imports — so they coexist cleanly with the PyPI package.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if ! command -v protoc >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: protoc not found in PATH." >&2
|
||||||
|
echo " macOS: brew install protobuf" >&2
|
||||||
|
echo " Ubuntu/Debian: apt install protobuf-compiler" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT=bin/_generated
|
||||||
|
LOCAL_NS=meshtastic_v25
|
||||||
|
|
||||||
|
rm -rf "$OUT"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# 1) Generate from the in-tree protos. nanopb.proto first so its descriptor
|
||||||
|
# is available for the [(nanopb).*] options on other messages.
|
||||||
|
protoc \
|
||||||
|
--proto_path=protobufs \
|
||||||
|
--python_out="$OUT" \
|
||||||
|
protobufs/nanopb.proto \
|
||||||
|
protobufs/meshtastic/*.proto
|
||||||
|
|
||||||
|
# 2) Move the generated `meshtastic/` directory to `meshtastic_v25/`.
|
||||||
|
mv "$OUT/meshtastic" "$OUT/$LOCAL_NS"
|
||||||
|
|
||||||
|
# 3) Rewrite internal imports: any reference to `meshtastic.X_pb2` or
|
||||||
|
# `from meshtastic import X_pb2` becomes `meshtastic_v25.*`.
|
||||||
|
python3 bin/_rewrite_proto_namespace.py "$OUT/$LOCAL_NS" "$LOCAL_NS"
|
||||||
|
|
||||||
|
# 4) Make the package importable.
|
||||||
|
touch "$OUT/__init__.py"
|
||||||
|
touch "$OUT/$LOCAL_NS/__init__.py"
|
||||||
|
|
||||||
|
echo "regenerated Python protobuf bindings -> $OUT/$LOCAL_NS/ (namespace: $LOCAL_NS)" >&2
|
||||||
Executable
+342
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compile a committed seed JSONL into a binary meshtastic_NodeDatabase v25 proto.
|
||||||
|
|
||||||
|
The input is produced by `bin/gen-fake-nodedb-seed.py`. Timestamps in the JSONL
|
||||||
|
are stored as `*_offset_sec` (seconds before "now"); this script resolves them
|
||||||
|
to absolute epochs using `--now-epoch` (default: current wall clock).
|
||||||
|
|
||||||
|
Output is a raw `pb_encode`-compatible binary that can be dropped at
|
||||||
|
`/prefs/nodes.proto` on the device (Portduino prefs dir or hardware via
|
||||||
|
XModem) and loaded by `NodeDB::loadFromDisk` at boot.
|
||||||
|
|
||||||
|
Wire format reference:
|
||||||
|
protobufs/meshtastic/deviceonly.proto (NodeDatabase, NodeInfoLite, sat entries)
|
||||||
|
src/mesh/NodeDB.h:467-484 (bitfield bit positions)
|
||||||
|
src/mesh/NodeDB.cpp:1523-1524 (pb_decode entry point)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Prefer the in-tree generated Python protobuf bindings (bin/_generated/meshtastic_v25/)
|
||||||
|
# because the firmware branch's protos (v25 NodeDatabase satellite arrays, slim
|
||||||
|
# NodeInfoLite) are typically newer than what the PyPI `meshtastic` package
|
||||||
|
# ships. Run `bin/regen-py-protos.sh` to (re)generate.
|
||||||
|
#
|
||||||
|
# Namespace note: the local bindings live under `meshtastic_v25` (NOT `meshtastic`)
|
||||||
|
# to avoid shadowing the PyPI `meshtastic` package — bin/regen-py-protos.sh
|
||||||
|
# post-processes the protoc output to rename the package.
|
||||||
|
_HERE = pathlib.Path(__file__).resolve().parent
|
||||||
|
_LOCAL_PROTO_DIR = _HERE / "_generated"
|
||||||
|
if _LOCAL_PROTO_DIR.is_dir():
|
||||||
|
sys.path.insert(0, str(_LOCAL_PROTO_DIR))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from meshtastic_v25.deviceonly_pb2 import ( # type: ignore[import-not-found]
|
||||||
|
NodeDatabase,
|
||||||
|
NodeInfoLite,
|
||||||
|
NodePositionEntry,
|
||||||
|
NodeTelemetryEntry,
|
||||||
|
NodeEnvironmentEntry,
|
||||||
|
NodeStatusEntry,
|
||||||
|
PositionLite,
|
||||||
|
)
|
||||||
|
from meshtastic_v25.mesh_pb2 import HardwareModel, Position, StatusMessage # type: ignore[import-not-found]
|
||||||
|
from meshtastic_v25.config_pb2 import Config # type: ignore[import-not-found]
|
||||||
|
from meshtastic_v25.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics # type: ignore[import-not-found]
|
||||||
|
except ImportError as local_err:
|
||||||
|
# Fall back to the PyPI package if in-tree bindings haven't been generated.
|
||||||
|
# Will fail the v25 assertion below if the PyPI package predates the
|
||||||
|
# satellite-DB schema, but at least gives a clear "run regen-py-protos.sh"
|
||||||
|
# error message instead of an opaque ImportError.
|
||||||
|
try:
|
||||||
|
from meshtastic.protobuf.deviceonly_pb2 import (
|
||||||
|
NodeDatabase,
|
||||||
|
NodeInfoLite,
|
||||||
|
NodePositionEntry,
|
||||||
|
NodeTelemetryEntry,
|
||||||
|
NodeEnvironmentEntry,
|
||||||
|
NodeStatusEntry,
|
||||||
|
PositionLite,
|
||||||
|
)
|
||||||
|
from meshtastic.protobuf.mesh_pb2 import HardwareModel, Position, StatusMessage
|
||||||
|
from meshtastic.protobuf.config_pb2 import Config
|
||||||
|
from meshtastic.protobuf.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics
|
||||||
|
except ImportError as pypi_err:
|
||||||
|
print(
|
||||||
|
"ERROR: could not import meshtastic protobuf bindings.\n"
|
||||||
|
" In-tree generation: run `bin/regen-py-protos.sh` (requires protoc).\n"
|
||||||
|
" PyPI fallback: `pip install meshtastic` (may lag firmware branch).\n"
|
||||||
|
f" local error (meshtastic_v25): {local_err}\n"
|
||||||
|
f" pypi error (meshtastic.protobuf): {pypi_err}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Fail loudly if bindings predate v25 (no satellite arrays).
|
||||||
|
assert (
|
||||||
|
hasattr(NodeDatabase, "DESCRIPTOR")
|
||||||
|
and "positions" in NodeDatabase.DESCRIPTOR.fields_by_name
|
||||||
|
), (
|
||||||
|
"Loaded meshtastic bindings are older than v25 (NodeDatabase.positions missing). "
|
||||||
|
"Run `bin/regen-py-protos.sh` against the in-tree protobufs/ submodule."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bitfield bit positions (mirror src/mesh/NodeDB.h:467-484).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BIT_IS_KEY_MANUALLY_VERIFIED = 0
|
||||||
|
BIT_IS_MUTED = 1
|
||||||
|
BIT_VIA_MQTT = 2
|
||||||
|
BIT_IS_FAVORITE = 3
|
||||||
|
BIT_IS_IGNORED = 4
|
||||||
|
BIT_HAS_USER = 5
|
||||||
|
BIT_IS_LICENSED = 6
|
||||||
|
BIT_IS_UNMESSAGABLE = 7
|
||||||
|
BIT_HAS_IS_UNMESSAGABLE = 8
|
||||||
|
|
||||||
|
BITFIELD_LAYOUT = (
|
||||||
|
# JSON key bit position
|
||||||
|
("is_key_manually_verified", BIT_IS_KEY_MANUALLY_VERIFIED),
|
||||||
|
("is_muted", BIT_IS_MUTED),
|
||||||
|
("via_mqtt", BIT_VIA_MQTT),
|
||||||
|
("is_favorite", BIT_IS_FAVORITE),
|
||||||
|
("is_ignored", BIT_IS_IGNORED),
|
||||||
|
("has_user", BIT_HAS_USER),
|
||||||
|
("is_licensed", BIT_IS_LICENSED),
|
||||||
|
("is_unmessagable", BIT_IS_UNMESSAGABLE),
|
||||||
|
("has_is_unmessagable", BIT_HAS_IS_UNMESSAGABLE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_bitfield(bf: dict[str, bool]) -> int:
|
||||||
|
out = 0
|
||||||
|
for key, shift in BITFIELD_LAYOUT:
|
||||||
|
if bf.get(key, False):
|
||||||
|
out |= (1 << shift)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_node(node: dict[str, Any]) -> None:
|
||||||
|
"""Friendly errors so hand-editors get clear feedback."""
|
||||||
|
if "num" not in node or not isinstance(node["num"], str):
|
||||||
|
raise ValueError(f"node missing/invalid 'num' (must be hex string): {node!r}")
|
||||||
|
if "long_name" not in node:
|
||||||
|
raise ValueError(f"node {node['num']}: missing 'long_name'")
|
||||||
|
if len(node["long_name"]) > 24:
|
||||||
|
raise ValueError(
|
||||||
|
f"node {node['num']}: long_name {node['long_name']!r} is "
|
||||||
|
f"{len(node['long_name'])} chars; max 24 (nanopb max_size:25 minus NUL)"
|
||||||
|
)
|
||||||
|
if "short_name" in node:
|
||||||
|
# short_name max_size:5 (incl. NUL) → 4 bytes of content.
|
||||||
|
# Char count is irrelevant — emojis with variation selectors (e.g. ❄️ = 6 B)
|
||||||
|
# would slip past a `len(str) > 4` check. Always measure bytes.
|
||||||
|
b = node["short_name"].encode("utf-8")
|
||||||
|
if len(b) > 4:
|
||||||
|
raise ValueError(
|
||||||
|
f"node {node['num']}: short_name {node['short_name']!r} is "
|
||||||
|
f"{len(b)} bytes UTF-8; max 4 (nanopb max_size:5 minus NUL)"
|
||||||
|
)
|
||||||
|
pk = node.get("public_key_hex", "")
|
||||||
|
if pk and len(pk) != 64:
|
||||||
|
raise ValueError(
|
||||||
|
f"node {node['num']}: public_key_hex must be 64 hex chars or empty; "
|
||||||
|
f"got {len(pk)} chars"
|
||||||
|
)
|
||||||
|
if pk:
|
||||||
|
try:
|
||||||
|
bytes.fromhex(pk)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"node {node['num']}: public_key_hex is not valid hex: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_time(
|
||||||
|
node: dict[str, Any],
|
||||||
|
field_absolute: str,
|
||||||
|
field_offset: str,
|
||||||
|
now_epoch: int,
|
||||||
|
) -> int:
|
||||||
|
"""If `field_absolute` is set, use it; else compute `now_epoch - offset`."""
|
||||||
|
if field_absolute in node and node[field_absolute] is not None:
|
||||||
|
return int(node[field_absolute])
|
||||||
|
offset = node.get(field_offset, 0)
|
||||||
|
return max(0, int(now_epoch) - int(offset))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_node_info_lite(node: dict[str, Any], now_epoch: int) -> NodeInfoLite:
|
||||||
|
_validate_node(node)
|
||||||
|
info = NodeInfoLite()
|
||||||
|
info.num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
|
||||||
|
info.long_name = node.get("long_name", "")
|
||||||
|
info.short_name = node.get("short_name", "")
|
||||||
|
# Enum lookups will raise ValueError on unknown names — that's exactly what we want.
|
||||||
|
info.hw_model = HardwareModel.Value(node.get("hw_model", "UNSET"))
|
||||||
|
info.role = Config.DeviceConfig.Role.Value(node.get("role", "CLIENT"))
|
||||||
|
pk_hex = node.get("public_key_hex", "")
|
||||||
|
if pk_hex:
|
||||||
|
info.public_key = bytes.fromhex(pk_hex)
|
||||||
|
info.snr = float(node.get("snr", 0.0))
|
||||||
|
info.channel = int(node.get("channel", 0))
|
||||||
|
if "hops_away" in node:
|
||||||
|
# `optional uint32 hops_away = 9;` — in Python protobuf, assigning the
|
||||||
|
# field implicitly sets HasField("hops_away") to True. No has_hops_away
|
||||||
|
# setter exists (unlike the C++ nanopb-generated header).
|
||||||
|
info.hops_away = int(node["hops_away"])
|
||||||
|
info.next_hop = int(node.get("next_hop", 0))
|
||||||
|
info.last_heard = _resolve_time(node, "last_heard", "last_heard_offset_sec", now_epoch)
|
||||||
|
info.bitfield = _pack_bitfield(node.get("bitfield", {}))
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _build_position_entry(num: int, pos: dict[str, Any], now_epoch: int) -> NodePositionEntry:
|
||||||
|
entry = NodePositionEntry()
|
||||||
|
entry.num = num
|
||||||
|
pl = PositionLite()
|
||||||
|
# Firmware stores lat/long as int32 in 1e-7 degrees.
|
||||||
|
pl.latitude_i = int(round(float(pos["latitude"]) * 1e7))
|
||||||
|
pl.longitude_i = int(round(float(pos["longitude"]) * 1e7))
|
||||||
|
pl.altitude = int(pos.get("altitude", 0))
|
||||||
|
pl.time = _resolve_time(pos, "time", "time_offset_sec", now_epoch)
|
||||||
|
pl.location_source = Position.LocSource.Value(pos.get("location_source", "LOC_UNSET"))
|
||||||
|
entry.position.CopyFrom(pl)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _build_telemetry_entry(num: int, tel: dict[str, Any]) -> NodeTelemetryEntry:
|
||||||
|
entry = NodeTelemetryEntry()
|
||||||
|
entry.num = num
|
||||||
|
dm = DeviceMetrics()
|
||||||
|
if "battery_level" in tel:
|
||||||
|
dm.battery_level = int(tel["battery_level"])
|
||||||
|
if "voltage" in tel:
|
||||||
|
dm.voltage = float(tel["voltage"])
|
||||||
|
if "channel_utilization" in tel:
|
||||||
|
dm.channel_utilization = float(tel["channel_utilization"])
|
||||||
|
if "air_util_tx" in tel:
|
||||||
|
dm.air_util_tx = float(tel["air_util_tx"])
|
||||||
|
if "uptime_seconds" in tel:
|
||||||
|
dm.uptime_seconds = int(tel["uptime_seconds"])
|
||||||
|
entry.device_metrics.CopyFrom(dm)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _build_environment_entry(num: int, env: dict[str, Any]) -> NodeEnvironmentEntry:
|
||||||
|
entry = NodeEnvironmentEntry()
|
||||||
|
entry.num = num
|
||||||
|
em = EnvironmentMetrics()
|
||||||
|
if "temperature" in env:
|
||||||
|
em.temperature = float(env["temperature"])
|
||||||
|
if "relative_humidity" in env:
|
||||||
|
em.relative_humidity = float(env["relative_humidity"])
|
||||||
|
if "barometric_pressure" in env:
|
||||||
|
em.barometric_pressure = float(env["barometric_pressure"])
|
||||||
|
if "iaq" in env:
|
||||||
|
em.iaq = int(env["iaq"])
|
||||||
|
entry.environment_metrics.CopyFrom(em)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _build_status_entry(num: int, status: dict[str, Any]) -> NodeStatusEntry:
|
||||||
|
# `StatusMessage` (mesh.proto:1445) has a single `string status` field.
|
||||||
|
entry = NodeStatusEntry()
|
||||||
|
entry.num = num
|
||||||
|
sm = StatusMessage()
|
||||||
|
if "status" in status:
|
||||||
|
sm.status = str(status["status"])
|
||||||
|
entry.status.CopyFrom(sm)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def compile_jsonl_to_proto(jsonl_path: pathlib.Path, now_epoch: int) -> bytes:
|
||||||
|
"""Read a seed JSONL and return the encoded NodeDatabase bytes."""
|
||||||
|
lines = jsonl_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if not lines:
|
||||||
|
raise ValueError(f"{jsonl_path} is empty")
|
||||||
|
meta_line = lines[0]
|
||||||
|
meta_obj = json.loads(meta_line)
|
||||||
|
meta = meta_obj.get("_meta", {})
|
||||||
|
version = meta.get("version")
|
||||||
|
if version != 25:
|
||||||
|
raise ValueError(
|
||||||
|
f"{jsonl_path}: meta version is {version!r}; this compiler "
|
||||||
|
f"requires version=25. Regenerate the seed with the matching tooling."
|
||||||
|
)
|
||||||
|
|
||||||
|
db = NodeDatabase()
|
||||||
|
db.version = 25
|
||||||
|
|
||||||
|
for ln, raw in enumerate(lines[1:], start=2):
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
node = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"{jsonl_path}:{ln} JSON parse error: {e}")
|
||||||
|
|
||||||
|
num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
|
||||||
|
|
||||||
|
# Header
|
||||||
|
info = _build_node_info_lite(node, now_epoch)
|
||||||
|
db.nodes.append(info)
|
||||||
|
|
||||||
|
# Satellites (nullable)
|
||||||
|
if node.get("position"):
|
||||||
|
db.positions.append(_build_position_entry(num, node["position"], now_epoch))
|
||||||
|
if node.get("telemetry"):
|
||||||
|
db.telemetry.append(_build_telemetry_entry(num, node["telemetry"]))
|
||||||
|
if node.get("environment"):
|
||||||
|
db.environment.append(_build_environment_entry(num, node["environment"]))
|
||||||
|
if node.get("status"):
|
||||||
|
db.status.append(_build_status_entry(num, node["status"]))
|
||||||
|
|
||||||
|
return db.SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Compile a seed JSONL into a binary v25 NodeDatabase proto.",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
p.add_argument("--in", dest="in_path", required=True, help="Input seed JSONL.")
|
||||||
|
p.add_argument("--out", required=True, help="Output binary .proto path.")
|
||||||
|
p.add_argument(
|
||||||
|
"--now-epoch",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Pin 'now' to this Unix epoch (for byte-identical CI). Default: time.time().",
|
||||||
|
)
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
in_path = pathlib.Path(args.in_path)
|
||||||
|
if not in_path.is_file():
|
||||||
|
print(f"input not found: {in_path}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
now_epoch = args.now_epoch if args.now_epoch is not None else int(time.time())
|
||||||
|
|
||||||
|
try:
|
||||||
|
encoded = compile_jsonl_to_proto(in_path, now_epoch)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"ERROR: {e}", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
out_path = pathlib.Path(args.out)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_bytes(encoded)
|
||||||
|
print(
|
||||||
|
f"compiled {in_path} -> {out_path} ({len(encoded)} bytes, now_epoch={now_epoch})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"arduino": {
|
||||||
|
"ldscript": "esp32s3_out.ld",
|
||||||
|
"memory_type": "qio_opi"
|
||||||
|
},
|
||||||
|
"core": "esp32",
|
||||||
|
"extra_flags": [
|
||||||
|
"-D BOARD_HAS_PSRAM",
|
||||||
|
"-D ARDUINO_USB_CDC_ON_BOOT=0",
|
||||||
|
"-D ARDUINO_USB_MODE=0",
|
||||||
|
"-D ARDUINO_RUNNING_CORE=1",
|
||||||
|
"-D ARDUINO_EVENT_RUNNING_CORE=0"
|
||||||
|
],
|
||||||
|
"f_cpu": "240000000L",
|
||||||
|
"f_flash": "80000000L",
|
||||||
|
"flash_mode": "qio",
|
||||||
|
"psram_type": "qio_opi",
|
||||||
|
"hwids": [["0x303A", "0x1001"]],
|
||||||
|
"mcu": "esp32s3",
|
||||||
|
"variant": "ELECROW-ThinkNode-M7"
|
||||||
|
},
|
||||||
|
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||||
|
"debug": {
|
||||||
|
"default_tool": "esp-builtin",
|
||||||
|
"onboard_tools": ["esp-builtin"],
|
||||||
|
"openocd_target": "esp32s3.cfg"
|
||||||
|
},
|
||||||
|
"frameworks": ["arduino", "espidf"],
|
||||||
|
"name": "ELECROW ThinkNode M7",
|
||||||
|
"upload": {
|
||||||
|
"flash_size": "8MB",
|
||||||
|
"maximum_ram_size": 524288,
|
||||||
|
"maximum_size": 8388608,
|
||||||
|
"use_1200bps_touch": true,
|
||||||
|
"wait_for_upload_port": true,
|
||||||
|
"require_upload_port": true,
|
||||||
|
"speed": 921600
|
||||||
|
},
|
||||||
|
"url": "https://www.elecrow.com",
|
||||||
|
"vendor": "ELECROW"
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ __pycache__/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# Persistent device-log capture (recorder + Datadog cursor).
|
||||||
|
# Cross-session JSONL streams written by the autouse Recorder singleton
|
||||||
|
# (see src/meshtastic_mcp/recorder/). Lives outside tests/ so the pytest
|
||||||
|
# fixture truncate doesn't touch it.
|
||||||
|
.mtlog/
|
||||||
|
|
||||||
# Test harness artifacts
|
# Test harness artifacts
|
||||||
tests/report.html
|
tests/report.html
|
||||||
tests/junit.xml
|
tests/junit.xml
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
{
|
||||||
|
"title": "Meshtastic Firmware — Recorder Stream",
|
||||||
|
"description": "Live view of `.mtlog/` streams shipped by `mtlog_to_datadog.py`. Heap, packet volume, log levels, errors. One row per port.",
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Free heap (bytes)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"show_legend": true,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "free_heap",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.local.heap_free_bytes{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": { "label": "bytes" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Heap slope (bytes/min) — last 1h",
|
||||||
|
"type": "query_value",
|
||||||
|
"precision": 0,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "slope",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "derivative(avg:mesh.local.heap_free_bytes{service:meshtastic-firmware})",
|
||||||
|
"aggregator": "avg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "scalar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditional_formats": [
|
||||||
|
{ "comparator": "<", "value": -100, "palette": "white_on_red" },
|
||||||
|
{ "comparator": "<", "value": 0, "palette": "white_on_yellow" },
|
||||||
|
{ "comparator": ">=", "value": 0, "palette": "white_on_green" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Total heap (bytes)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "total_heap",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.local.heap_total_bytes{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Battery level (%)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "battery",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.device.battery_level{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": { "min": "0", "max": "105" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Air utilization (TX %)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "airutil",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.device.air_util_tx{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Channel utilization (%)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "chutil",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.device.channel_utilization{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Log volume by level",
|
||||||
|
"type": "timeseries",
|
||||||
|
"show_legend": true,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "bars",
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "log_count",
|
||||||
|
"data_source": "logs",
|
||||||
|
"indexes": ["*"],
|
||||||
|
"compute": { "aggregation": "count" },
|
||||||
|
"search": { "query": "service:meshtastic-firmware" },
|
||||||
|
"group_by": [
|
||||||
|
{
|
||||||
|
"facet": "@level",
|
||||||
|
"limit": 10,
|
||||||
|
"sort": { "order": "desc", "aggregation": "count" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Recent ERROR / CRIT firmware logs",
|
||||||
|
"type": "list_stream",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"response_format": "event_list",
|
||||||
|
"query": {
|
||||||
|
"data_source": "logs_stream",
|
||||||
|
"query_string": "service:meshtastic-firmware (status:error OR @level:ERROR OR @level:CRIT)",
|
||||||
|
"indexes": [],
|
||||||
|
"sort": { "column": "timestamp", "order": "desc" }
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{ "field": "timestamp", "width": "auto" },
|
||||||
|
{ "field": "host", "width": "auto" },
|
||||||
|
{ "field": "@port", "width": "auto" },
|
||||||
|
{ "field": "@level", "width": "auto" },
|
||||||
|
{ "field": "@thread", "width": "auto" },
|
||||||
|
{ "field": "message", "width": "stretch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Recorder marker events",
|
||||||
|
"type": "list_stream",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"response_format": "event_list",
|
||||||
|
"query": {
|
||||||
|
"data_source": "logs_stream",
|
||||||
|
"query_string": "service:meshtastic-firmware @level:MARK",
|
||||||
|
"indexes": [],
|
||||||
|
"sort": { "column": "timestamp", "order": "desc" }
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{ "field": "timestamp", "width": "auto" },
|
||||||
|
{ "field": "host", "width": "auto" },
|
||||||
|
{ "field": "message", "width": "stretch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"template_variables": [
|
||||||
|
{
|
||||||
|
"name": "port",
|
||||||
|
"prefix": "port",
|
||||||
|
"available_values": [],
|
||||||
|
"default": "*"
|
||||||
|
},
|
||||||
|
{ "name": "host", "prefix": "host", "available_values": [], "default": "*" }
|
||||||
|
],
|
||||||
|
"layout_type": "ordered",
|
||||||
|
"notify_list": [],
|
||||||
|
"reflow_type": "auto"
|
||||||
|
}
|
||||||
Executable
+389
@@ -0,0 +1,389 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Forward selected recorder JSONL streams to Datadog.
|
||||||
|
|
||||||
|
Reads `.mtlog/logs.jsonl` and `.mtlog/telemetry.jsonl`, ships logs to the
|
||||||
|
Logs Intake API and telemetry numerics to the Metrics v2 series API.
|
||||||
|
Resumes from `.mtlog/.dd-cursor.json` so a daemon restart doesn't
|
||||||
|
duplicate rows already shipped from the current live files.
|
||||||
|
|
||||||
|
This forwarder does not currently backfill rotated `.jsonl.gz` archives.
|
||||||
|
If the recorder rotates before this process drains the live file, or the
|
||||||
|
forwarder is down across a rotation, those older rows are skipped.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
DD_API_KEY=... ./scripts/mtlog_to_datadog.py --tail
|
||||||
|
./scripts/mtlog_to_datadog.py --once # catch up + exit
|
||||||
|
./scripts/mtlog_to_datadog.py --since 3600 # backfill last hour from start
|
||||||
|
|
||||||
|
Default `DD_SITE` is `us5.datadoghq.com` — the team's Datadog instance.
|
||||||
|
Override via `DD_SITE=...` env var or `--site` flag for one-offs.
|
||||||
|
|
||||||
|
The forwarder is a separate process by design — a Datadog outage or
|
||||||
|
auth failure must not backpressure the recorder. We exit non-zero on
|
||||||
|
fatal config errors (missing API key) and keep retrying on transient
|
||||||
|
network/HTTP errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"requests is required. Install it in the mcp-server venv: "
|
||||||
|
"uv pip install requests",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_LOG_DIR = Path(__file__).resolve().parents[1] / ".mtlog"
|
||||||
|
_LOG_INTAKE_TPL = "https://http-intake.logs.{site}/api/v2/logs"
|
||||||
|
_METRICS_TPL = "https://api.{site}/api/v2/series"
|
||||||
|
_LOG_BATCH = 50
|
||||||
|
_METRICS_BATCH = 100
|
||||||
|
_MAX_RETRIES = 5
|
||||||
|
_RETRY_BASE_S = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
# --- streaming JSONL with byte-position cursor -------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamReader:
|
||||||
|
"""Reads a single rotating JSONL with cursor-based resume.
|
||||||
|
|
||||||
|
This tails only the live `.jsonl` file. The recorder rotates files
|
||||||
|
(live `.jsonl` → `.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`), which means
|
||||||
|
the live file shrinks abruptly. We detect that via inode change OR live
|
||||||
|
size < cursor position, and reset the live-file cursor to 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, cursor: dict[str, Any]):
|
||||||
|
self.path = path
|
||||||
|
self.cursor = cursor
|
||||||
|
|
||||||
|
def _state(self) -> tuple[int, int]:
|
||||||
|
"""Return (inode, size) for the live file. (0, 0) if missing."""
|
||||||
|
try:
|
||||||
|
st = self.path.stat()
|
||||||
|
return (st.st_ino, st.st_size)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
def iter_new_records(self) -> Iterator[dict[str, Any]]:
|
||||||
|
ino, size = self._state()
|
||||||
|
last_ino = self.cursor.get("ino")
|
||||||
|
last_pos = int(self.cursor.get("pos") or 0)
|
||||||
|
if ino == 0:
|
||||||
|
return
|
||||||
|
if last_ino is not None and last_ino != ino:
|
||||||
|
# Rotation happened. Start over.
|
||||||
|
last_pos = 0
|
||||||
|
if last_pos > size:
|
||||||
|
# Live file truncated/shrunk under us — recorder rotated.
|
||||||
|
last_pos = 0
|
||||||
|
try:
|
||||||
|
with self.path.open("r", encoding="utf-8") as fh:
|
||||||
|
fh.seek(last_pos)
|
||||||
|
for line in fh:
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
yield json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
last_pos = fh.tell()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
self.cursor["ino"] = ino
|
||||||
|
self.cursor["pos"] = last_pos
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cursor(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cursor(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
tmp = path.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(data, separators=(",", ":")))
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Datadog clients ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _DDSession:
|
||||||
|
"""Pool one HTTPS session, share retry logic."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, site: str, hostname: str) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
self.site = site
|
||||||
|
self.hostname = hostname
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"DD-API-KEY": api_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _post(self, url: str, payload: Any) -> bool:
|
||||||
|
for attempt in range(_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
resp = self.session.post(url, json=payload, timeout=30)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_wait_retry(attempt, f"network error: {e}")
|
||||||
|
continue
|
||||||
|
if 200 <= resp.status_code < 300:
|
||||||
|
return True
|
||||||
|
if resp.status_code in (408, 429, 500, 502, 503, 504):
|
||||||
|
_wait_retry(
|
||||||
|
attempt,
|
||||||
|
f"HTTP {resp.status_code} retrying",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
print(
|
||||||
|
f"datadog refused: {resp.status_code} {resp.text[:200]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_logs(self, records: list[dict[str, Any]]) -> int:
|
||||||
|
if not records:
|
||||||
|
return 0
|
||||||
|
url = _LOG_INTAKE_TPL.format(site=self.site)
|
||||||
|
sent = 0
|
||||||
|
for i in range(0, len(records), _LOG_BATCH):
|
||||||
|
batch = records[i : i + _LOG_BATCH]
|
||||||
|
if self._post(url, batch):
|
||||||
|
sent += len(batch)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
def send_metrics(self, series: list[dict[str, Any]]) -> int:
|
||||||
|
if not series:
|
||||||
|
return 0
|
||||||
|
url = _METRICS_TPL.format(site=self.site)
|
||||||
|
sent = 0
|
||||||
|
for i in range(0, len(series), _METRICS_BATCH):
|
||||||
|
batch = series[i : i + _METRICS_BATCH]
|
||||||
|
if self._post(url, {"series": batch}):
|
||||||
|
sent += len(batch)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_retry(attempt: int, reason: str) -> None:
|
||||||
|
wait = _RETRY_BASE_S * (2**attempt)
|
||||||
|
print(
|
||||||
|
f" retry {attempt + 1}/{_MAX_RETRIES} in {wait:.1f}s ({reason})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
|
||||||
|
# --- record → datadog payload ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _log_record_to_dd(rec: dict[str, Any], host: str) -> dict[str, Any]:
|
||||||
|
line = rec.get("line") or ""
|
||||||
|
tags = [
|
||||||
|
f"role:{rec.get('role')}",
|
||||||
|
f"port:{rec.get('port')}",
|
||||||
|
]
|
||||||
|
level = rec.get("level")
|
||||||
|
if level:
|
||||||
|
tags.append(f"level:{level}")
|
||||||
|
tag = rec.get("tag")
|
||||||
|
if tag:
|
||||||
|
tags.append(f"thread:{tag}")
|
||||||
|
return {
|
||||||
|
"ddsource": "meshtastic-firmware",
|
||||||
|
"service": "meshtastic-firmware",
|
||||||
|
"hostname": host,
|
||||||
|
"message": line,
|
||||||
|
"ddtags": ",".join(t for t in tags if t and "None" not in t),
|
||||||
|
"timestamp": int((rec.get("ts") or time.time()) * 1000),
|
||||||
|
"level": level,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _telemetry_record_to_metrics(
|
||||||
|
rec: dict[str, Any], host: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
fields = rec.get("fields") or {}
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
return []
|
||||||
|
variant = rec.get("variant") or "unknown"
|
||||||
|
ts = int(rec.get("ts") or time.time())
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
tags = []
|
||||||
|
if rec.get("port"):
|
||||||
|
tags.append(f"port:{rec['port']}")
|
||||||
|
if rec.get("role"):
|
||||||
|
tags.append(f"role:{rec['role']}")
|
||||||
|
if rec.get("from_node"):
|
||||||
|
tags.append(f"from_node:{rec['from_node']}")
|
||||||
|
tags.append(f"variant:{variant}")
|
||||||
|
for field, value in fields.items():
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
|
continue
|
||||||
|
metric = f"mesh.{variant}.{_metric_safe(field)}"
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"metric": metric,
|
||||||
|
"type": 3, # GAUGE
|
||||||
|
"points": [{"timestamp": ts, "value": float(value)}],
|
||||||
|
"tags": tags,
|
||||||
|
"resources": [{"type": "host", "name": host}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_safe(name: str) -> str:
|
||||||
|
# Lowercase, replace non-alnum with underscore for safe metric names.
|
||||||
|
return "".join(c.lower() if c.isalnum() else "_" for c in name)
|
||||||
|
|
||||||
|
|
||||||
|
# --- main loop ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
log_dir: Path,
|
||||||
|
*,
|
||||||
|
once: bool,
|
||||||
|
since_seconds: float | None,
|
||||||
|
poll_interval: float,
|
||||||
|
dd: _DDSession,
|
||||||
|
) -> int:
|
||||||
|
cursor_path = log_dir / ".dd-cursor.json"
|
||||||
|
cursors = _load_cursor(cursor_path)
|
||||||
|
|
||||||
|
# `--since` overrides cursor: rewind to (now-since) timestamp.
|
||||||
|
# We can't seek by timestamp directly (cursor is byte position), so
|
||||||
|
# we just reset cursors to 0 and let the time filter in iter_new
|
||||||
|
# drop older records.
|
||||||
|
cutoff_ts: float | None = None
|
||||||
|
if since_seconds is not None:
|
||||||
|
cursors = {}
|
||||||
|
cutoff_ts = time.time() - since_seconds
|
||||||
|
|
||||||
|
sent_total = {"logs": 0, "telemetry": 0}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# logs.jsonl → DD logs
|
||||||
|
log_cursor = cursors.setdefault("logs", {})
|
||||||
|
log_batch: list[dict[str, Any]] = []
|
||||||
|
for rec in _StreamReader(log_dir / "logs.jsonl", log_cursor).iter_new_records():
|
||||||
|
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
|
||||||
|
continue
|
||||||
|
log_batch.append(_log_record_to_dd(rec, dd.hostname))
|
||||||
|
if log_batch:
|
||||||
|
n = dd.send_logs(log_batch)
|
||||||
|
sent_total["logs"] += n
|
||||||
|
print(f"logs: sent {n}/{len(log_batch)}")
|
||||||
|
|
||||||
|
# telemetry.jsonl → DD metrics
|
||||||
|
telem_cursor = cursors.setdefault("telemetry", {})
|
||||||
|
metric_series: list[dict[str, Any]] = []
|
||||||
|
for rec in _StreamReader(
|
||||||
|
log_dir / "telemetry.jsonl", telem_cursor
|
||||||
|
).iter_new_records():
|
||||||
|
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
|
||||||
|
continue
|
||||||
|
metric_series.extend(_telemetry_record_to_metrics(rec, dd.hostname))
|
||||||
|
if metric_series:
|
||||||
|
n = dd.send_metrics(metric_series)
|
||||||
|
sent_total["telemetry"] += n
|
||||||
|
print(f"telemetry: sent {n}/{len(metric_series)} metric points")
|
||||||
|
|
||||||
|
_save_cursor(cursor_path, cursors)
|
||||||
|
|
||||||
|
if once:
|
||||||
|
print(f"done. logs={sent_total['logs']} metrics={sent_total['telemetry']}")
|
||||||
|
return 0
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-dir",
|
||||||
|
default=str(_DEFAULT_LOG_DIR),
|
||||||
|
help="Path to .mtlog/ (default: mcp-server/.mtlog)",
|
||||||
|
)
|
||||||
|
mode = parser.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument("--once", action="store_true", help="Catch up then exit")
|
||||||
|
mode.add_argument(
|
||||||
|
"--tail",
|
||||||
|
action="store_true",
|
||||||
|
help="Daemon: poll forever (default)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--since",
|
||||||
|
type=float,
|
||||||
|
default=None,
|
||||||
|
help="Backfill last N seconds. Resets cursor.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--poll-interval",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="Seconds between tail polls (default 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--site",
|
||||||
|
default=os.environ.get("DD_SITE", "us5.datadoghq.com"),
|
||||||
|
help=(
|
||||||
|
"Datadog site. Default is the team's instance (us5.datadoghq.com). "
|
||||||
|
"Override via DD_SITE env var or this flag."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default=socket.gethostname(),
|
||||||
|
help="Hostname tag (default: socket.gethostname())",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
api_key = os.environ.get("DD_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("DD_API_KEY env var required.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
log_dir = Path(args.log_dir)
|
||||||
|
if not log_dir.exists():
|
||||||
|
print(
|
||||||
|
f"log dir {log_dir} does not exist — start the mcp-server first.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
dd = _DDSession(api_key=api_key, site=args.site, hostname=args.host)
|
||||||
|
once = args.once and not args.tail
|
||||||
|
return run(
|
||||||
|
log_dir,
|
||||||
|
once=once,
|
||||||
|
since_seconds=args.since,
|
||||||
|
poll_interval=args.poll_interval,
|
||||||
|
dd=dd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload.
|
||||||
|
|
||||||
|
The fixture pipeline is two-stage:
|
||||||
|
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
|
||||||
|
fake-but-realistic peers. Committed under `test/fixtures/nodedb/`.
|
||||||
|
2. `bin/seed-json-to-proto.py` compiles JSONL → binary v25 NodeDatabase
|
||||||
|
protobuf with fresh wall-clock timestamps.
|
||||||
|
|
||||||
|
This module exposes `push_fake_nodedb(...)`, the MCP tool that:
|
||||||
|
- target="portduino": compiles the JSONL into the device's prefs dir on
|
||||||
|
the local filesystem (`~/.portduino/<config>/prefs/nodes.proto`).
|
||||||
|
- target="hardware": compiles to a temp file, then streams it over the
|
||||||
|
XModem protocol (via the meshtastic SerialInterface/BLEInterface +
|
||||||
|
`meshtastic.xmodempacket` pubsub topic) to `/prefs/nodes.proto` on the
|
||||||
|
device. Triggers a reboot so the firmware loads the new state on next
|
||||||
|
boot.
|
||||||
|
|
||||||
|
XModem wire details (mirrors firmware impl at src/xmodem.cpp:115-260):
|
||||||
|
* 128-byte chunks; final chunk padded to 128 B with 0x1A (SUB) bytes.
|
||||||
|
* CRC16-CCITT (poly 0x1021, init 0x0000).
|
||||||
|
* SOH/seq=0 carries the destination filename in `buffer.bytes`. ACK if
|
||||||
|
`FSCom.open(filename, FILE_O_WRITE)` succeeds; NAK otherwise.
|
||||||
|
* SOH/seq≥1 carries a 128-byte chunk. ACK = advance; NAK = retransmit.
|
||||||
|
* EOT after the last chunk flushes + closes the file on-device.
|
||||||
|
|
||||||
|
Hardware push requires `confirm=True` (mirrors factory_reset / erase_and_flash
|
||||||
|
in the .github/copilot-instructions.md "never do these without asking" list).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import hashlib
|
||||||
|
import pathlib
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from .connection import connect, is_tcp_port
|
||||||
|
|
||||||
|
# Resolve repo root so the tool works regardless of mcp-server cwd.
|
||||||
|
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
_SEED_DIR = _REPO_ROOT / "test" / "fixtures" / "nodedb"
|
||||||
|
_COMPILE_SCRIPT = _REPO_ROOT / "bin" / "seed-json-to-proto.py"
|
||||||
|
|
||||||
|
_DEFAULT_NODES_FILENAME = "/prefs/nodes.proto"
|
||||||
|
_XMODEM_CHUNK = 128
|
||||||
|
_XMODEM_SUB = 0x1A
|
||||||
|
_ACK_TIMEOUT_INIT_S = 5.0
|
||||||
|
_ACK_TIMEOUT_CHUNK_S = 2.0
|
||||||
|
_MAX_CHUNK_RETRIES = 5
|
||||||
|
|
||||||
|
_VALID_SIZES = (250, 500, 1000, 2000)
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureError(RuntimeError):
|
||||||
|
"""Raised for any fixture-push failure (compile, transport, ack timeout, …)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRC16-CCITT (poly 0x1021, init 0x0000). Matches the firmware's `crc16_ccitt`.
|
||||||
|
# Hand-rolled to avoid the optional `crcmod` dep.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
|
||||||
|
crc = init
|
||||||
|
for b in data:
|
||||||
|
crc ^= b << 8
|
||||||
|
for _ in range(8):
|
||||||
|
if crc & 0x8000:
|
||||||
|
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
|
||||||
|
else:
|
||||||
|
crc = (crc << 1) & 0xFFFF
|
||||||
|
return crc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module
|
||||||
|
# doesn't have to duplicate the proto-encoding logic.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
|
||||||
|
if not _COMPILE_SCRIPT.is_file():
|
||||||
|
raise FixtureError(f"compile script missing at {_COMPILE_SCRIPT}")
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(_COMPILE_SCRIPT),
|
||||||
|
"--in",
|
||||||
|
str(jsonl_path),
|
||||||
|
"--out",
|
||||||
|
str(out_path),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise FixtureError(
|
||||||
|
f"seed-json-to-proto.py failed (exit {exc.returncode}):\n"
|
||||||
|
f" stdout: {exc.stdout}\n stderr: {exc.stderr}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
|
||||||
|
if custom is not None:
|
||||||
|
p = pathlib.Path(custom).expanduser().resolve()
|
||||||
|
if not p.is_file():
|
||||||
|
raise FixtureError(f"custom_seed_jsonl not found: {p}")
|
||||||
|
return p
|
||||||
|
p = _SEED_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||||
|
if not p.is_file():
|
||||||
|
raise FixtureError(
|
||||||
|
f"missing committed seed at {p}. "
|
||||||
|
f"Run `./bin/regen-fake-nodedbs.sh` to generate it."
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Portduino push — file copy into ~/.portduino/<config>/prefs/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
|
||||||
|
home = pathlib.Path.home()
|
||||||
|
return home / ".portduino" / config_name / "prefs"
|
||||||
|
|
||||||
|
|
||||||
|
def _push_portduino(
|
||||||
|
size: int,
|
||||||
|
jsonl: pathlib.Path,
|
||||||
|
portduino_config: str,
|
||||||
|
backup_existing: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
prefs = _portduino_prefs_dir(portduino_config)
|
||||||
|
prefs.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = prefs / "nodes.proto"
|
||||||
|
backed_up_to: str | None = None
|
||||||
|
if backup_existing and target.is_file():
|
||||||
|
ts = int(time.time())
|
||||||
|
backup = prefs / f"nodes.proto.bak.{ts}"
|
||||||
|
shutil.move(str(target), str(backup))
|
||||||
|
backed_up_to = str(backup)
|
||||||
|
_compile_proto(jsonl, target)
|
||||||
|
raw = target.read_bytes()
|
||||||
|
return {
|
||||||
|
"transport": "portduino",
|
||||||
|
"path": str(target),
|
||||||
|
"bytes": len(raw),
|
||||||
|
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
"jsonl_source": str(jsonl),
|
||||||
|
"backed_up_to": backed_up_to,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hardware push — XModem over BLE/serial via the meshtastic Python interface.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class _AckEvent:
|
||||||
|
control: int
|
||||||
|
seq: int
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEvent:
|
||||||
|
try:
|
||||||
|
return q.get(timeout=timeout_s)
|
||||||
|
except queue.Empty as exc:
|
||||||
|
raise FixtureError(
|
||||||
|
f"XModem response timeout after {timeout_s:.1f}s — device not responding"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _push_hardware(
|
||||||
|
size: int,
|
||||||
|
jsonl: pathlib.Path,
|
||||||
|
port: str | None,
|
||||||
|
reboot_after: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Lazy imports so the module loads even when the meshtastic deps aren't
|
||||||
|
# available (e.g. CI in a Python env without the package installed).
|
||||||
|
try:
|
||||||
|
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
|
||||||
|
from pubsub import pub
|
||||||
|
except ImportError as exc: # pragma: no cover — dep missing
|
||||||
|
raise FixtureError(
|
||||||
|
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if is_tcp_port(port):
|
||||||
|
raise FixtureError(
|
||||||
|
"hardware push over TCP/portduino is not supported — use "
|
||||||
|
"target='portduino' to drop the fixture directly into the prefs dir."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compile the fixture to a temp file with fresh timestamps.
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".proto", delete=False) as tf:
|
||||||
|
proto_path = pathlib.Path(tf.name)
|
||||||
|
try:
|
||||||
|
_compile_proto(jsonl, proto_path)
|
||||||
|
payload = proto_path.read_bytes()
|
||||||
|
finally:
|
||||||
|
proto_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
sha256 = hashlib.sha256(payload).hexdigest()
|
||||||
|
total_bytes = len(payload)
|
||||||
|
|
||||||
|
# Subscribe to XModem responses BEFORE we open the interface, so we don't
|
||||||
|
# race the first ACK that arrives during the SOH/seq=0 handshake.
|
||||||
|
#
|
||||||
|
# NB: the signature MUST declare every kwarg pypubsub will see for this
|
||||||
|
# topic, or pubsub locks the topic spec to a smaller set (whichever
|
||||||
|
# subscribe arrives first) and then *rejects* the meshtastic library's
|
||||||
|
# publish call with `SenderUnknownMsgDataError: unknown ... interface`.
|
||||||
|
# The meshtastic lib publishes both `packet=` and `interface=`
|
||||||
|
# (mesh_interface.py:1389-1395), so both must appear here.
|
||||||
|
response_q: "queue.Queue[_AckEvent]" = queue.Queue()
|
||||||
|
|
||||||
|
def _on_xmodem(packet: Any = None, interface: Any = None, **_kw: Any) -> None:
|
||||||
|
if packet is None:
|
||||||
|
return
|
||||||
|
response_q.put(_AckEvent(control=int(packet.control), seq=int(packet.seq)))
|
||||||
|
|
||||||
|
pub.subscribe(_on_xmodem, "meshtastic.xmodempacket")
|
||||||
|
|
||||||
|
chunks_sent = 0
|
||||||
|
retried = 0
|
||||||
|
rebooted = False
|
||||||
|
|
||||||
|
XMC = xmodem_pb2.XModem.Control
|
||||||
|
try:
|
||||||
|
with connect(port=port) as iface:
|
||||||
|
# 1) Send the filename (SOH, seq=0).
|
||||||
|
init_pkt = xmodem_pb2.XModem(
|
||||||
|
control=XMC.Value("SOH"),
|
||||||
|
seq=0,
|
||||||
|
buffer=_DEFAULT_NODES_FILENAME.encode("utf-8"),
|
||||||
|
)
|
||||||
|
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=init_pkt))
|
||||||
|
ack = _wait_for_response(response_q, _ACK_TIMEOUT_INIT_S)
|
||||||
|
if ack.control != XMC.Value("ACK"):
|
||||||
|
raise FixtureError(
|
||||||
|
f"device refused filename {_DEFAULT_NODES_FILENAME!r} "
|
||||||
|
f"(got control={ack.control}, expected ACK). "
|
||||||
|
f"Filesystem full or permissions issue?"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Stream the payload in 128 B chunks.
|
||||||
|
for offset in range(0, total_bytes, _XMODEM_CHUNK):
|
||||||
|
chunk = payload[offset : offset + _XMODEM_CHUNK]
|
||||||
|
if len(chunk) < _XMODEM_CHUNK:
|
||||||
|
# Pad final chunk to 128 B with SUB. The trailing 0x1A bytes
|
||||||
|
# become part of the file on-device, but nanopb ignores
|
||||||
|
# bytes past the end of the top-level message.
|
||||||
|
chunk = chunk + bytes([_XMODEM_SUB] * (_XMODEM_CHUNK - len(chunk)))
|
||||||
|
seq = ((offset // _XMODEM_CHUNK) + 1) % 256
|
||||||
|
# Retry loop on NAK / timeout.
|
||||||
|
attempts = 0
|
||||||
|
while True:
|
||||||
|
pkt = xmodem_pb2.XModem(
|
||||||
|
control=XMC.Value("SOH"),
|
||||||
|
seq=seq,
|
||||||
|
buffer=chunk,
|
||||||
|
crc16=_crc16_ccitt(chunk),
|
||||||
|
)
|
||||||
|
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=pkt))
|
||||||
|
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
|
||||||
|
if ack.control == XMC.Value("ACK"):
|
||||||
|
chunks_sent += 1
|
||||||
|
break
|
||||||
|
if ack.control == XMC.Value("NAK"):
|
||||||
|
attempts += 1
|
||||||
|
retried += 1
|
||||||
|
if attempts >= _MAX_CHUNK_RETRIES:
|
||||||
|
# Abort: send CAN so the firmware removes the half-
|
||||||
|
# written file via FSCom.remove(filename).
|
||||||
|
iface._sendToRadio(
|
||||||
|
mesh_pb2.ToRadio(
|
||||||
|
xmodemPacket=xmodem_pb2.XModem(
|
||||||
|
control=XMC.Value("CAN")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise FixtureError(
|
||||||
|
f"chunk seq={seq} NAK'd {attempts} times; "
|
||||||
|
f"aborted transfer (file removed on-device)."
|
||||||
|
)
|
||||||
|
continue # retry the same chunk
|
||||||
|
raise FixtureError(
|
||||||
|
f"unexpected XModem control={ack.control} on seq={seq}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) Tell the device we're done.
|
||||||
|
iface._sendToRadio(
|
||||||
|
mesh_pb2.ToRadio(
|
||||||
|
xmodemPacket=xmodem_pb2.XModem(control=XMC.Value("EOT"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
|
||||||
|
if ack.control != XMC.Value("ACK"):
|
||||||
|
raise FixtureError(f"EOT not ACKed (got control={ack.control})")
|
||||||
|
|
||||||
|
# 4) Reboot so loadFromDisk picks up the new file.
|
||||||
|
if reboot_after:
|
||||||
|
iface.localNode.reboot(secs=1)
|
||||||
|
rebooted = True
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
pub.unsubscribe(_on_xmodem, "meshtastic.xmodempacket")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transport": "hardware",
|
||||||
|
"port": port,
|
||||||
|
"filename_on_device": _DEFAULT_NODES_FILENAME,
|
||||||
|
"bytes": total_bytes,
|
||||||
|
"chunks_sent": chunks_sent,
|
||||||
|
"retried": retried,
|
||||||
|
"sha256": sha256,
|
||||||
|
"jsonl_source": str(jsonl),
|
||||||
|
"rebooted": rebooted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point — registered as an MCP tool in server.py.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def push_fake_nodedb(
|
||||||
|
size: int,
|
||||||
|
target: Literal["portduino", "hardware"] = "portduino",
|
||||||
|
*,
|
||||||
|
port: str | None = None,
|
||||||
|
portduino_config: str = "default",
|
||||||
|
backup_existing: bool = True,
|
||||||
|
confirm: bool = False,
|
||||||
|
reboot_after: bool = True,
|
||||||
|
custom_seed_jsonl: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use.
|
||||||
|
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
|
||||||
|
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
|
||||||
|
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
|
||||||
|
or BLE identifier. TCP endpoints are rejected — use target="portduino"
|
||||||
|
instead.
|
||||||
|
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
|
||||||
|
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
|
||||||
|
if present, so you can roll back.
|
||||||
|
confirm: required True for target="hardware" (writes flash + reboots).
|
||||||
|
reboot_after: hardware only. If True, send a 1-second reboot after the
|
||||||
|
final ACK so loadFromDisk picks up the new file at next boot.
|
||||||
|
custom_seed_jsonl: override the committed JSONL. Use to push a hand-edited
|
||||||
|
test scenario.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with transport, bytes, sha256, etc. — depends on target.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if size not in _VALID_SIZES:
|
||||||
|
raise FixtureError(
|
||||||
|
f"size must be one of {_VALID_SIZES}; got {size!r}. "
|
||||||
|
f"Add a new committed seed if you need a different cardinality."
|
||||||
|
)
|
||||||
|
|
||||||
|
jsonl = _resolve_seed_jsonl(size, custom_seed_jsonl)
|
||||||
|
|
||||||
|
if target == "portduino":
|
||||||
|
return _push_portduino(size, jsonl, portduino_config, backup_existing)
|
||||||
|
|
||||||
|
if target == "hardware":
|
||||||
|
if not confirm:
|
||||||
|
raise FixtureError(
|
||||||
|
"hardware push writes flash and triggers a reboot — pass confirm=True."
|
||||||
|
)
|
||||||
|
if not port:
|
||||||
|
raise FixtureError(
|
||||||
|
"target='hardware' requires a port (e.g. /dev/cu.usbmodemXXXX)."
|
||||||
|
)
|
||||||
|
return _push_hardware(size, jsonl, port, reboot_after)
|
||||||
|
|
||||||
|
raise FixtureError(f"unknown target {target!r}; expected 'portduino' or 'hardware'")
|
||||||
@@ -108,18 +108,33 @@ def build(
|
|||||||
env: str,
|
env: str,
|
||||||
with_manifest: bool = True,
|
with_manifest: bool = True,
|
||||||
userprefs_overrides: dict[str, Any] | None = None,
|
userprefs_overrides: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run `pio run -e <env>` and return artifact paths.
|
"""Run `pio run -e <env>` and return artifact paths.
|
||||||
|
|
||||||
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
|
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
|
||||||
into userPrefs.jsonc for this build only. File is restored byte-for-byte
|
into userPrefs.jsonc for this build only. File is restored byte-for-byte
|
||||||
on exit. Use `userprefs_set()` for persistent changes.
|
on exit. Use `userprefs_set()` for persistent changes.
|
||||||
|
|
||||||
|
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros to set for
|
||||||
|
this build only via `PLATFORMIO_BUILD_FLAGS`. Common useful flag:
|
||||||
|
`{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]`
|
||||||
|
prefix on every log line. Combines with the recorder so heap shows
|
||||||
|
up at log cadence (much higher resolution than the ~60 s LocalStats
|
||||||
|
packet) — see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
|
||||||
|
expand to bare `-D<NAME>` (presence-only flags).
|
||||||
"""
|
"""
|
||||||
args = ["run", "-e", env]
|
args = ["run", "-e", env]
|
||||||
if with_manifest:
|
if with_manifest:
|
||||||
args.extend(["-t", "mtjson"])
|
args.extend(["-t", "mtjson"])
|
||||||
|
extra_env = _build_flags_env(build_flags) if build_flags else None
|
||||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||||
result = pio.run(args, timeout=pio.TIMEOUT_BUILD, check=False)
|
result = pio.run(
|
||||||
|
args,
|
||||||
|
timeout=pio.TIMEOUT_BUILD,
|
||||||
|
check=False,
|
||||||
|
extra_env=extra_env,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"exit_code": result.returncode,
|
"exit_code": result.returncode,
|
||||||
"artifacts": [str(p) for p in _artifacts_for(env)],
|
"artifacts": [str(p) for p in _artifacts_for(env)],
|
||||||
@@ -127,9 +142,27 @@ def build(
|
|||||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||||
"duration_s": round(result.duration_s, 2),
|
"duration_s": round(result.duration_s, 2),
|
||||||
"userprefs": _userprefs_summary(effective),
|
"userprefs": _userprefs_summary(effective),
|
||||||
|
"build_flags": dict(build_flags) if build_flags else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_flags_env(build_flags: dict[str, Any]) -> dict[str, str]:
|
||||||
|
"""Translate `{"DEBUG_HEAP": 1, "FOO": "bar"}` → `{"PLATFORMIO_BUILD_FLAGS":
|
||||||
|
"-DDEBUG_HEAP=1 -DFOO=bar"}`. Bool True → bare `-D<NAME>`; False/None drop
|
||||||
|
the flag entirely. Other types stringify."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for key, value in build_flags.items():
|
||||||
|
if value is False or value is None:
|
||||||
|
continue
|
||||||
|
if value is True:
|
||||||
|
parts.append(f"-D{key}")
|
||||||
|
else:
|
||||||
|
parts.append(f"-D{key}={value}")
|
||||||
|
if not parts:
|
||||||
|
return {}
|
||||||
|
return {"PLATFORMIO_BUILD_FLAGS": " ".join(parts)}
|
||||||
|
|
||||||
|
|
||||||
def clean(env: str) -> dict[str, Any]:
|
def clean(env: str) -> dict[str, Any]:
|
||||||
"""Run `pio run -e <env> -t clean`."""
|
"""Run `pio run -e <env> -t clean`."""
|
||||||
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
|
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
|
||||||
@@ -146,20 +179,29 @@ def flash(
|
|||||||
port: str,
|
port: str,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
userprefs_overrides: dict[str, Any] | None = None,
|
userprefs_overrides: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
||||||
|
|
||||||
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
||||||
that pio performs will pick up the injected values.
|
that pio performs will pick up the injected values.
|
||||||
|
|
||||||
|
`build_flags` (optional): same shape as `build()` — `PLATFORMIO_BUILD_FLAGS`
|
||||||
|
is exported for the rebuild-before-upload, so the uploaded firmware
|
||||||
|
actually carries the flags. Without this propagation, `pio run -t upload`
|
||||||
|
would relink without the env var and silently drop them. Common use:
|
||||||
|
`build_flags={"DEBUG_HEAP": 1}` for the leak-hunt path.
|
||||||
"""
|
"""
|
||||||
_require_confirm(confirm, "flash")
|
_require_confirm(confirm, "flash")
|
||||||
_reject_native_env(env, "flash")
|
_reject_native_env(env, "flash")
|
||||||
connection.reject_if_tcp(port, "flash")
|
connection.reject_if_tcp(port, "flash")
|
||||||
|
extra_env = _build_flags_env(build_flags) if build_flags else None
|
||||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||||
result = pio.run(
|
result = pio.run(
|
||||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||||
timeout=pio.TIMEOUT_UPLOAD,
|
timeout=pio.TIMEOUT_UPLOAD,
|
||||||
check=False,
|
check=False,
|
||||||
|
extra_env=extra_env,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"exit_code": result.returncode,
|
"exit_code": result.returncode,
|
||||||
@@ -167,6 +209,7 @@ def flash(
|
|||||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||||
"duration_s": round(result.duration_s, 2),
|
"duration_s": round(result.duration_s, 2),
|
||||||
"userprefs": _userprefs_summary(effective),
|
"userprefs": _userprefs_summary(effective),
|
||||||
|
"build_flags": dict(build_flags) if build_flags else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""Read-side queries over the recorder's JSONL streams.
|
||||||
|
|
||||||
|
Pure functions over `mcp-server/.mtlog/`. Streaming JSONL reader: never
|
||||||
|
loads a whole file. Time-bound queries short-circuit as soon as `ts`
|
||||||
|
exceeds the requested end. The recorder writes monotonically, so a
|
||||||
|
forward scan is cheap; we don't need an index.
|
||||||
|
|
||||||
|
All time arguments accept:
|
||||||
|
- epoch seconds (int/float)
|
||||||
|
- relative strings: "-15m", "-2h", "-3d", "now"
|
||||||
|
- ISO-ish absolute strings: "2026-05-07T14:30:00" (naive timestamps are
|
||||||
|
treated as UTC)
|
||||||
|
|
||||||
|
Tools that return data ALWAYS cap their output (max_lines / max_points
|
||||||
|
/ max), and report whether more matched than was returned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from .recorder.recorder import get_recorder
|
||||||
|
|
||||||
|
_REL_RE = re.compile(r"^\s*-\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$")
|
||||||
|
_REGEX_PREVIEW_MAX = 100
|
||||||
|
_REGEX_PREVIEW_TRUNCATE = 97
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: Any, *, now: float | None = None) -> float:
|
||||||
|
"""Coerce to epoch seconds. Defaults `now` to `time.time()`."""
|
||||||
|
if value is None:
|
||||||
|
return time.time()
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"invalid time: {value!r}")
|
||||||
|
s = value.strip().lower()
|
||||||
|
if s in ("", "now"):
|
||||||
|
return time.time() if now is None else now
|
||||||
|
m = _REL_RE.match(s)
|
||||||
|
if m:
|
||||||
|
n = float(m.group(1))
|
||||||
|
unit = m.group(2)
|
||||||
|
secs = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
||||||
|
base = time.time() if now is None else now
|
||||||
|
return base - secs
|
||||||
|
# Try ISO 8601. Accept naive (assume UTC) and Z-suffixed.
|
||||||
|
try:
|
||||||
|
if s.endswith("z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.timestamp()
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"unparseable time: {value!r}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_jsonl(path: Path, *, since: float, until: float) -> Iterator[dict[str, Any]]:
|
||||||
|
"""Stream records in chronological order: rotated archives first
|
||||||
|
(oldest → newest by lex sort, which is chronological for our
|
||||||
|
`YYYYMMDD-HHMMSS-uuuuuu-NNNNN` archive naming), then the live file
|
||||||
|
last. The "keep last N" pop-front logic in the window queries
|
||||||
|
relies on records arriving in time order across files.
|
||||||
|
"""
|
||||||
|
files: list[Path] = []
|
||||||
|
# Gzipped archives are named "<stem>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz".
|
||||||
|
for archive in sorted(path.parent.glob(f"{path.stem}.*.jsonl.gz")):
|
||||||
|
files.append(archive)
|
||||||
|
if path.exists():
|
||||||
|
files.append(path)
|
||||||
|
for f in files:
|
||||||
|
opener = gzip.open if f.suffix == ".gz" else open
|
||||||
|
try:
|
||||||
|
with opener(f, "rt", encoding="utf-8") as fh: # type: ignore[arg-type]
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
ts = rec.get("ts")
|
||||||
|
if not isinstance(ts, (int, float)):
|
||||||
|
continue
|
||||||
|
if ts < since:
|
||||||
|
continue
|
||||||
|
if ts > until:
|
||||||
|
# Records are append-monotonic within a file, so
|
||||||
|
# the rest of this file is also past `until`.
|
||||||
|
# Archives can still overlap each other, so only
|
||||||
|
# short-circuit this file, not the whole scan.
|
||||||
|
break
|
||||||
|
yield rec
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
# -- queries ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def logs_window(
|
||||||
|
start: Any = "-15m",
|
||||||
|
end: Any = "now",
|
||||||
|
*,
|
||||||
|
grep: str | None = None,
|
||||||
|
level: str | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
port: str | None = None,
|
||||||
|
max_lines: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recent firmware log lines, filtered.
|
||||||
|
|
||||||
|
`level` accepts a single level name or pipe-separated set
|
||||||
|
("WARN|ERROR|CRIT"). `grep` is a regex (Python re) over the raw
|
||||||
|
`line` field. Returns the last `max_lines` matches.
|
||||||
|
"""
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
levels = _split_set(level)
|
||||||
|
if grep:
|
||||||
|
try:
|
||||||
|
grep_re = re.compile(grep)
|
||||||
|
except re.error as exc:
|
||||||
|
preview = (
|
||||||
|
grep
|
||||||
|
if len(grep) <= _REGEX_PREVIEW_MAX
|
||||||
|
else f"{grep[:_REGEX_PREVIEW_TRUNCATE]}..."
|
||||||
|
)
|
||||||
|
raise ValueError(f"invalid grep regex {preview!r}: {exc}") from exc
|
||||||
|
else:
|
||||||
|
grep_re = None
|
||||||
|
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
matched = 0
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for rec in _iter_jsonl(base / "logs.jsonl", since=s, until=e):
|
||||||
|
if levels and rec.get("level") not in levels:
|
||||||
|
continue
|
||||||
|
if tag and rec.get("tag") != tag:
|
||||||
|
continue
|
||||||
|
if port and rec.get("port") != port:
|
||||||
|
continue
|
||||||
|
if grep_re and not grep_re.search(rec.get("line") or ""):
|
||||||
|
continue
|
||||||
|
matched += 1
|
||||||
|
out.append(rec)
|
||||||
|
if len(out) > max_lines:
|
||||||
|
out.pop(0) # keep the most recent N
|
||||||
|
return {
|
||||||
|
"lines": out,
|
||||||
|
"total_matched": matched,
|
||||||
|
"dropped": max(0, matched - max_lines),
|
||||||
|
"window": {"start": s, "end": e},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_timeline(
|
||||||
|
window: Any = "1h",
|
||||||
|
*,
|
||||||
|
variant: str = "local",
|
||||||
|
field: str = "free_heap",
|
||||||
|
port: str | None = None,
|
||||||
|
max_points: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Timeseries of one telemetry field, downsampled.
|
||||||
|
|
||||||
|
`field` matches both the protobuf snake_case name (`free_heap`,
|
||||||
|
`heap_free_bytes`, `battery_level`) and camelCase (`freeHeap`).
|
||||||
|
Server-side bucket-mean downsamples to ≤ `max_points`. Returns
|
||||||
|
`slope_per_min` (linear regression slope, units/min) so a leak
|
||||||
|
detector can read one number.
|
||||||
|
"""
|
||||||
|
end = time.time()
|
||||||
|
if isinstance(window, (int, float)):
|
||||||
|
# Numeric `window` is a duration in seconds — "last N seconds".
|
||||||
|
# Without this branch, `_parse_time(-N)` would treat -N as an
|
||||||
|
# absolute epoch timestamp (i.e., Jan 1 1970 minus N seconds),
|
||||||
|
# producing a wildly negative `start` and matching nothing.
|
||||||
|
start = end - float(window)
|
||||||
|
elif isinstance(window, str) and not window.startswith("-"):
|
||||||
|
# Bare string like "1h" is sugar for "-1h".
|
||||||
|
start = _parse_time(f"-{window}", now=end)
|
||||||
|
else:
|
||||||
|
start = _parse_time(window, now=end)
|
||||||
|
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
raw: list[tuple[float, float]] = []
|
||||||
|
field_aliases = _field_aliases(field)
|
||||||
|
for rec in _iter_jsonl(base / "telemetry.jsonl", since=start, until=end):
|
||||||
|
if rec.get("variant") != variant:
|
||||||
|
continue
|
||||||
|
if port and rec.get("port") != port:
|
||||||
|
continue
|
||||||
|
fields = rec.get("fields") or {}
|
||||||
|
value: Any = None
|
||||||
|
for alias in field_aliases:
|
||||||
|
if alias in fields:
|
||||||
|
value = fields[alias]
|
||||||
|
break
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
continue
|
||||||
|
raw.append((float(rec["ts"]), float(value)))
|
||||||
|
|
||||||
|
if not raw:
|
||||||
|
return {
|
||||||
|
"points": [],
|
||||||
|
"samples": 0,
|
||||||
|
"min": None,
|
||||||
|
"max": None,
|
||||||
|
"slope_per_min": None,
|
||||||
|
"window": {"start": start, "end": end, "variant": variant, "field": field},
|
||||||
|
}
|
||||||
|
|
||||||
|
points = _downsample(raw, max_points=max_points)
|
||||||
|
values = [v for _, v in raw]
|
||||||
|
return {
|
||||||
|
"points": [{"ts": ts, "value": v} for ts, v in points],
|
||||||
|
"samples": len(raw),
|
||||||
|
"min": min(values),
|
||||||
|
"max": max(values),
|
||||||
|
"slope_per_min": _slope_per_min(raw),
|
||||||
|
"window": {"start": start, "end": end, "variant": variant, "field": field},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def packets_window(
|
||||||
|
start: Any = "-5m",
|
||||||
|
end: Any = "now",
|
||||||
|
*,
|
||||||
|
portnum: str | None = None,
|
||||||
|
from_node: str | None = None,
|
||||||
|
to_node: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
portnums = _split_set(portnum)
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
matched = 0
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for rec in _iter_jsonl(base / "packets.jsonl", since=s, until=e):
|
||||||
|
if portnums and rec.get("portnum") not in portnums:
|
||||||
|
continue
|
||||||
|
if from_node and str(rec.get("from_node")) != str(from_node):
|
||||||
|
continue
|
||||||
|
if to_node and str(rec.get("to_node")) != str(to_node):
|
||||||
|
continue
|
||||||
|
matched += 1
|
||||||
|
out.append(rec)
|
||||||
|
if len(out) > max:
|
||||||
|
out.pop(0)
|
||||||
|
return {
|
||||||
|
"packets": out,
|
||||||
|
"total_matched": matched,
|
||||||
|
"dropped": matched - max if matched > max else 0,
|
||||||
|
"window": {"start": s, "end": e},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def events_window(
|
||||||
|
start: Any = "-1h",
|
||||||
|
end: Any = "now",
|
||||||
|
*,
|
||||||
|
kind: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
kinds = _split_set(kind)
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
matched = 0
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for rec in _iter_jsonl(base / "events.jsonl", since=s, until=e):
|
||||||
|
if kinds and rec.get("kind") not in kinds:
|
||||||
|
continue
|
||||||
|
matched += 1
|
||||||
|
out.append(rec)
|
||||||
|
if len(out) > max:
|
||||||
|
out.pop(0)
|
||||||
|
return {
|
||||||
|
"events": out,
|
||||||
|
"total_matched": matched,
|
||||||
|
"dropped": matched - max if matched > max else 0,
|
||||||
|
"window": {"start": s, "end": e},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def export(
|
||||||
|
start: Any,
|
||||||
|
end: Any,
|
||||||
|
dest_dir: str,
|
||||||
|
*,
|
||||||
|
streams: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Bundle a slice of each requested stream into `dest_dir`.
|
||||||
|
|
||||||
|
For a notebook, a bug report, or a Datadog backfill. Output files
|
||||||
|
are uncompressed JSONL (callers gzip themselves if they want to).
|
||||||
|
"""
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
selected = streams or ["logs", "telemetry", "packets", "events"]
|
||||||
|
dest = Path(dest_dir)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
paths: dict[str, str] = {}
|
||||||
|
for stream in selected:
|
||||||
|
src = base / f"{stream}.jsonl"
|
||||||
|
if not src.exists() and not list(base.glob(f"{stream}.*.jsonl.gz")):
|
||||||
|
continue
|
||||||
|
out_path = dest / f"{stream}.jsonl"
|
||||||
|
n = 0
|
||||||
|
with out_path.open("w", encoding="utf-8") as fh:
|
||||||
|
for rec in _iter_jsonl(src, since=s, until=e):
|
||||||
|
fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
|
||||||
|
n += 1
|
||||||
|
paths[stream] = str(out_path)
|
||||||
|
paths[f"{stream}_count"] = str(n)
|
||||||
|
return {"dest_dir": str(dest), "paths": paths, "window": {"start": s, "end": e}}
|
||||||
|
|
||||||
|
|
||||||
|
# -- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _split_set(value: str | None) -> set[str] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return {v.strip() for v in value.split("|") if v.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _field_aliases(field: str) -> list[str]:
|
||||||
|
"""Accept snake_case OR camelCase, plus a few legacy aliases."""
|
||||||
|
snake = field
|
||||||
|
camel = _snake_to_camel(field)
|
||||||
|
aliases = {snake, camel}
|
||||||
|
# Old protobuf fields (pre-LocalStats) used different names
|
||||||
|
legacy = {
|
||||||
|
"free_heap": ["free_heap", "freeHeap", "heap_free_bytes", "heapFreeBytes"],
|
||||||
|
"heap_free_bytes": [
|
||||||
|
"heap_free_bytes",
|
||||||
|
"heapFreeBytes",
|
||||||
|
"free_heap",
|
||||||
|
"freeHeap",
|
||||||
|
],
|
||||||
|
"total_heap": ["total_heap", "totalHeap", "heap_total_bytes", "heapTotalBytes"],
|
||||||
|
"heap_total_bytes": [
|
||||||
|
"heap_total_bytes",
|
||||||
|
"heapTotalBytes",
|
||||||
|
"total_heap",
|
||||||
|
"totalHeap",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if field in legacy:
|
||||||
|
aliases.update(legacy[field])
|
||||||
|
return list(aliases)
|
||||||
|
|
||||||
|
|
||||||
|
def _snake_to_camel(name: str) -> str:
|
||||||
|
parts = name.split("_")
|
||||||
|
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _downsample(
|
||||||
|
points: list[tuple[float, float]], *, max_points: int
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
if len(points) <= max_points:
|
||||||
|
return points
|
||||||
|
# Even-bucket mean. Preserves shape better than nth-sample picking.
|
||||||
|
n = len(points)
|
||||||
|
bucket = n / max_points
|
||||||
|
out: list[tuple[float, float]] = []
|
||||||
|
i = 0
|
||||||
|
for k in range(max_points):
|
||||||
|
end = int((k + 1) * bucket)
|
||||||
|
end = min(end, n)
|
||||||
|
if end <= i:
|
||||||
|
continue
|
||||||
|
chunk = points[i:end]
|
||||||
|
ts = chunk[len(chunk) // 2][0]
|
||||||
|
val = statistics.fmean(v for _, v in chunk)
|
||||||
|
out.append((ts, val))
|
||||||
|
i = end
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _slope_per_min(points: list[tuple[float, float]]) -> float | None:
|
||||||
|
"""Least-squares slope (units per minute). None if too few points."""
|
||||||
|
if len(points) < 2:
|
||||||
|
return None
|
||||||
|
xs = [t for t, _ in points]
|
||||||
|
ys = [v for _, v in points]
|
||||||
|
n = len(xs)
|
||||||
|
mean_x = sum(xs) / n
|
||||||
|
mean_y = sum(ys) / n
|
||||||
|
num = sum((xs[i] - mean_x) * (ys[i] - mean_y) for i in range(n))
|
||||||
|
den = sum((x - mean_x) ** 2 for x in xs)
|
||||||
|
if den == 0:
|
||||||
|
return None
|
||||||
|
slope_per_sec = num / den
|
||||||
|
return slope_per_sec * 60.0
|
||||||
@@ -92,6 +92,7 @@ def _run_capturing(
|
|||||||
cwd: Path | None = None,
|
cwd: Path | None = None,
|
||||||
timeout: float | None = None,
|
timeout: float | None = None,
|
||||||
tee_header: str | None = None,
|
tee_header: str | None = None,
|
||||||
|
extra_env: dict[str, str] | None = None,
|
||||||
) -> tuple[int, str, str, float]:
|
) -> tuple[int, str, str, float]:
|
||||||
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
|
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
|
||||||
|
|
||||||
@@ -99,6 +100,9 @@ def _run_capturing(
|
|||||||
`subprocess.TimeoutExpired` on timeout (callers map this to their own
|
`subprocess.TimeoutExpired` on timeout (callers map this to their own
|
||||||
domain-specific error).
|
domain-specific error).
|
||||||
|
|
||||||
|
`extra_env` merges into the subprocess environment (parent env stays
|
||||||
|
intact). Used for `PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar.
|
||||||
|
|
||||||
Fast path: `subprocess.run(capture_output=True)` when no flash log is
|
Fast path: `subprocess.run(capture_output=True)` when no flash log is
|
||||||
configured (unchanged behavior).
|
configured (unchanged behavior).
|
||||||
|
|
||||||
@@ -110,6 +114,9 @@ def _run_capturing(
|
|||||||
"""
|
"""
|
||||||
log_path = _flash_log_path()
|
log_path = _flash_log_path()
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
env = None
|
||||||
|
if extra_env:
|
||||||
|
env = {**os.environ, **extra_env}
|
||||||
|
|
||||||
if log_path is None:
|
if log_path is None:
|
||||||
# Fast path — unchanged.
|
# Fast path — unchanged.
|
||||||
@@ -119,6 +126,7 @@ def _run_capturing(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
proc.returncode,
|
proc.returncode,
|
||||||
@@ -145,6 +153,7 @@ def _run_capturing(
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1, # line-buffered
|
bufsize=1, # line-buffered
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
stdout_chunks: list[str] = []
|
stdout_chunks: list[str] = []
|
||||||
stderr_chunks: list[str] = []
|
stderr_chunks: list[str] = []
|
||||||
@@ -232,12 +241,17 @@ def run(
|
|||||||
cwd: Path | None = None,
|
cwd: Path | None = None,
|
||||||
timeout: float | None = TIMEOUT_DEFAULT,
|
timeout: float | None = TIMEOUT_DEFAULT,
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
|
extra_env: dict[str, str] | None = None,
|
||||||
) -> PioResult:
|
) -> PioResult:
|
||||||
"""Invoke `pio <args>` and return captured output.
|
"""Invoke `pio <args>` and return captured output.
|
||||||
|
|
||||||
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
||||||
non-zero exit; set `check=False` to inspect `returncode` manually.
|
non-zero exit; set `check=False` to inspect `returncode` manually.
|
||||||
|
|
||||||
|
`extra_env` merges into the subprocess environment — used for
|
||||||
|
`PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time
|
||||||
|
toggles that can't be expressed as command-line args.
|
||||||
|
|
||||||
If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file
|
If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file
|
||||||
line-by-line as it arrives (for live flash progress in the TUI).
|
line-by-line as it arrives (for live flash progress in the TUI).
|
||||||
"""
|
"""
|
||||||
@@ -250,6 +264,7 @@ def run(
|
|||||||
cwd=work_dir,
|
cwd=work_dir,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
tee_header=f"pio {' '.join(args)}",
|
tee_header=f"pio {' '.join(args)}",
|
||||||
|
extra_env=extra_env,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
|
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Persistent device-log capture.
|
||||||
|
|
||||||
|
Singleton `Recorder` subscribes once to the meshtastic pubsub fan-out
|
||||||
|
(`meshtastic.log.line`, `meshtastic.receive.*`, `meshtastic.connection.*`)
|
||||||
|
and appends to four JSONL files under `mcp-server/.mtlog/`. Pubsub is
|
||||||
|
process-global so a single subscription captures every active interface
|
||||||
|
(serial / TCP / BLE) without any per-connection bookkeeping.
|
||||||
|
|
||||||
|
The recorder is opt-in-by-import: importing this package is a no-op; call
|
||||||
|
`get_recorder().start()` (which `server.py` does at FastMCP app init) to
|
||||||
|
begin writing. `pause()` / `resume()` exist for the rare case the user
|
||||||
|
wants a clean stretch of file (e.g. capturing a known-good baseline).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .recorder import Recorder, get_recorder
|
||||||
|
|
||||||
|
__all__ = ["Recorder", "get_recorder"]
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""Best-effort parsers for log lines and telemetry packets.
|
||||||
|
|
||||||
|
Two flavors of log line cross our pubsub subscription:
|
||||||
|
1. Text-mode path (debug_log_api disabled): the meshtastic Python lib
|
||||||
|
accumulates bytes between protobuf frames and emits the full
|
||||||
|
firmware-formatted line, e.g.
|
||||||
|
"INFO | 12:34:56 12345 [Main] Booting"
|
||||||
|
— level, HH:MM:SS, uptime seconds, thread bracket, then message.
|
||||||
|
2. LogRecord protobuf path (debug_log_api enabled): the lib calls
|
||||||
|
`_handleLogLine(record.message)` with ONLY the message body. The
|
||||||
|
level/source/time fields on the LogRecord are dropped before
|
||||||
|
pubsub fan-out. We get e.g. just "Booting".
|
||||||
|
|
||||||
|
Both arrive on `meshtastic.log.line`. The parser tries to recover a
|
||||||
|
level + thread when the prefix is present and falls back to level=None
|
||||||
|
otherwise. Consumers who want level filtering on protobuf-mode hosts
|
||||||
|
should grep the raw `line` field instead.
|
||||||
|
|
||||||
|
Telemetry: `meshtastic.receive.telemetry` packets carry one of several
|
||||||
|
metric variants in `packet["decoded"]["telemetry"]`. We flatten the
|
||||||
|
chosen variant into a {field: value} dict so callers don't have to
|
||||||
|
know the protobuf shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Match: LEVEL | HH:MM:SS UPTIME [Thread] message
|
||||||
|
# HH:MM:SS may be ??:??:?? when RTC isn't valid. The level alternation
|
||||||
|
# below is the canonical list — DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
|
||||||
|
# macros must stay in sync with these strings.
|
||||||
|
_LINE_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
^
|
||||||
|
(?P<level>DEBUG|INFO\ |WARN\ |ERROR|CRIT\ |TRACE|HEAP\ )
|
||||||
|
\s*\|\s*
|
||||||
|
(?P<clock>(?:\d{2}:\d{2}:\d{2})|(?:\?{2}:\?{2}:\?{2}))
|
||||||
|
\s+
|
||||||
|
(?P<uptime>\d+)
|
||||||
|
\s+
|
||||||
|
(?:\[(?P<thread>[^\]]+)\]\s+)?
|
||||||
|
(?P<msg>.*)
|
||||||
|
$
|
||||||
|
""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# DEBUG_HEAP build prepends `[heap N] ` to every message body, AFTER the
|
||||||
|
# thread bracket. See src/RedirectablePrint.cpp:175.
|
||||||
|
_HEAP_PREFIX_RE = re.compile(r"^\[heap\s+(?P<heap>\d+)\]\s+(?P<rest>.*)$")
|
||||||
|
|
||||||
|
# OSThread leak/free detection. See src/concurrency/OSThread.cpp:89-91.
|
||||||
|
# Format: "------ Thread NAME leaked heap A -> B (delta) ------"
|
||||||
|
# "++++++ Thread NAME freed heap A -> B (delta) ++++++"
|
||||||
|
_THREAD_HEAP_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
^[\-+]+\s*
|
||||||
|
Thread\s+(?P<thread>\S+)\s+
|
||||||
|
(?P<kind>leaked|freed)\s+heap\s+
|
||||||
|
(?P<before>-?\d+)\s*->\s*(?P<after>-?\d+)\s+
|
||||||
|
\((?P<delta>-?\d+)\)
|
||||||
|
""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Power.cpp:908 periodic heap status (DEBUG_HEAP only).
|
||||||
|
# Format: "Heap status: FREE/TOTAL bytes free (DELTA), running R/N threads"
|
||||||
|
_HEAP_STATUS_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
Heap\s+status:\s+
|
||||||
|
(?P<free>\d+)\s*/\s*(?P<total>\d+)\s+bytes\s+free
|
||||||
|
(?:\s+\((?P<delta>-?\d+)\))?
|
||||||
|
""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
|
_HEAP_BRACKET_RE = re.compile(r"^heap\s+(?P<heap>\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log_line(line: str) -> dict[str, Any]:
|
||||||
|
"""Best-effort decompose a raw firmware log line.
|
||||||
|
|
||||||
|
Returns a dict with at least `line` (the original, unmodified — ANSI
|
||||||
|
codes preserved for fidelity). Adds `level`, `tag`, `clock`,
|
||||||
|
`uptime_s`, and `msg` when the full prefix is present.
|
||||||
|
|
||||||
|
Handles two firmware quirks:
|
||||||
|
- LogRecord.message can carry ANSI color escapes from RedirectablePrint
|
||||||
|
(the BLE/StreamAPI path inherited the colored body in some builds).
|
||||||
|
We strip ANSI before regex matching so the prefix survives.
|
||||||
|
- DEBUG_HEAP injects `[heap N]` after the thread bracket. When NO
|
||||||
|
thread name is set, the heap takes the thread bracket position —
|
||||||
|
looks like `[heap 12345] msg`. We detect that shape and move it
|
||||||
|
out of `tag` and into `heap_free`.
|
||||||
|
|
||||||
|
DEBUG_HEAP-build extras (when `[heap N]` is injected): `heap_free`
|
||||||
|
(bytes), and when a `Thread X leaked|freed heap` line is recognized,
|
||||||
|
`heap_event` = {kind, thread, before, after, delta}.
|
||||||
|
|
||||||
|
Never raises.
|
||||||
|
"""
|
||||||
|
out: dict[str, Any] = {"line": line}
|
||||||
|
if not line:
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Strip ANSI escapes BEFORE any regex matching. The original `line`
|
||||||
|
# stays in `out["line"]` for fidelity / future grep.
|
||||||
|
clean = _ANSI_RE.sub("", line)
|
||||||
|
|
||||||
|
m = _LINE_RE.match(clean)
|
||||||
|
msg: str | None = None
|
||||||
|
if m:
|
||||||
|
level = m.group("level").rstrip()
|
||||||
|
out["level"] = level
|
||||||
|
out["clock"] = m.group("clock")
|
||||||
|
try:
|
||||||
|
out["uptime_s"] = int(m.group("uptime"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
out["uptime_s"] = None
|
||||||
|
thread = m.group("thread")
|
||||||
|
if thread:
|
||||||
|
# If "thread" is actually the heap prefix taking the bracket
|
||||||
|
# position (DEBUG_HEAP build, no thread set), capture heap
|
||||||
|
# and leave tag unset.
|
||||||
|
hb = _HEAP_BRACKET_RE.match(thread.strip())
|
||||||
|
if hb:
|
||||||
|
try:
|
||||||
|
out["heap_free"] = int(hb.group("heap"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
out["tag"] = thread
|
||||||
|
msg = m.group("msg")
|
||||||
|
out["msg"] = msg
|
||||||
|
else:
|
||||||
|
# No prefix — bare LogRecord.message body. Inspect the whole
|
||||||
|
# line for DEBUG_HEAP-style content; the heap-prefix and
|
||||||
|
# thread-leak patterns can survive on either path.
|
||||||
|
msg = clean
|
||||||
|
|
||||||
|
# DEBUG_HEAP per-line heap prefix: `[heap 92344] message`.
|
||||||
|
# Sits AFTER the thread bracket and BEFORE the message body, but
|
||||||
|
# for bare LogRecord lines it's at the start. Match it at the
|
||||||
|
# head of `msg`.
|
||||||
|
if msg:
|
||||||
|
hp = _HEAP_PREFIX_RE.match(msg)
|
||||||
|
if hp:
|
||||||
|
try:
|
||||||
|
out["heap_free"] = int(hp.group("heap"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Strip the prefix from `msg` so a grep on the message
|
||||||
|
# body doesn't have to know about it.
|
||||||
|
out["msg"] = hp.group("rest")
|
||||||
|
msg = hp.group("rest")
|
||||||
|
|
||||||
|
# Thread-level leak/free detection.
|
||||||
|
thr = _THREAD_HEAP_RE.search(msg)
|
||||||
|
if thr:
|
||||||
|
try:
|
||||||
|
out["heap_event"] = {
|
||||||
|
"kind": thr.group("kind"),
|
||||||
|
"thread": thr.group("thread"),
|
||||||
|
"before": int(thr.group("before")),
|
||||||
|
"after": int(thr.group("after")),
|
||||||
|
"delta": int(thr.group("delta")),
|
||||||
|
}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Power.cpp periodic "Heap status: F/T bytes free (D), running ..."
|
||||||
|
hs = _HEAP_STATUS_RE.search(msg)
|
||||||
|
if hs:
|
||||||
|
try:
|
||||||
|
out["heap_free"] = int(hs.group("free"))
|
||||||
|
out["heap_total"] = int(hs.group("total"))
|
||||||
|
if hs.group("delta") is not None:
|
||||||
|
out["heap_delta"] = int(hs.group("delta"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# -- Telemetry ----------------------------------------------------------
|
||||||
|
|
||||||
|
# Order matters: meshtastic-python decoded packets use the protobuf
|
||||||
|
# `oneof variant` field name (snake_case) as the dict key.
|
||||||
|
_TELEMETRY_VARIANTS = (
|
||||||
|
("device_metrics", "device"),
|
||||||
|
("local_stats", "local"),
|
||||||
|
("environment_metrics", "environment"),
|
||||||
|
("power_metrics", "power"),
|
||||||
|
("air_quality_metrics", "airQuality"),
|
||||||
|
("health_metrics", "health"),
|
||||||
|
("host_metrics", "host"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_telemetry(packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Pull the telemetry variant + flat fields out of a `meshtastic.receive.telemetry`
|
||||||
|
packet. Returns None when the shape isn't what we expect — so the
|
||||||
|
caller can fall back to a generic packets.jsonl row.
|
||||||
|
"""
|
||||||
|
if not isinstance(packet, dict):
|
||||||
|
return None
|
||||||
|
decoded = packet.get("decoded")
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
return None
|
||||||
|
telem = decoded.get("telemetry")
|
||||||
|
if not isinstance(telem, dict):
|
||||||
|
return None
|
||||||
|
# The Python lib produces dict-of-camelCase keys via MessageToDict.
|
||||||
|
# Try both camelCase and snake_case to be robust to lib version drift.
|
||||||
|
for snake, label in _TELEMETRY_VARIANTS:
|
||||||
|
camel = _snake_to_camel(snake)
|
||||||
|
for key in (snake, camel):
|
||||||
|
value = telem.get(key)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
"variant": label,
|
||||||
|
"fields": {k: _scalarize(v) for k, v in value.items()},
|
||||||
|
"time": telem.get("time"),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snake_to_camel(name: str) -> str:
|
||||||
|
parts = name.split("_")
|
||||||
|
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _scalarize(value: Any) -> Any:
|
||||||
|
"""Keep telemetry fields JSON-friendly. Lists/dicts pass through
|
||||||
|
untouched; bytes -> hex string; protobuf enums occasionally arrive
|
||||||
|
as ints (fine) or strings (also fine)."""
|
||||||
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||||
|
return bytes(value).hex()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# -- Generic packet summary ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_packet(
|
||||||
|
packet: dict[str, Any], *, payload_hex_len: int = 64
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reduce a packet dict to a stable, queryable summary. Drops the
|
||||||
|
full payload bytes — the recorder records summaries, not pcaps.
|
||||||
|
"""
|
||||||
|
if not isinstance(packet, dict):
|
||||||
|
return {"raw_type": type(packet).__name__}
|
||||||
|
decoded = packet.get("decoded") if isinstance(packet.get("decoded"), dict) else {}
|
||||||
|
portnum = decoded.get("portnum") if isinstance(decoded, dict) else None
|
||||||
|
payload = decoded.get("payload") if isinstance(decoded, dict) else None
|
||||||
|
payload_hex = None
|
||||||
|
payload_size = None
|
||||||
|
if isinstance(payload, (bytes, bytearray, memoryview)):
|
||||||
|
b = bytes(payload)
|
||||||
|
payload_size = len(b)
|
||||||
|
payload_hex = b[:payload_hex_len].hex() if b else ""
|
||||||
|
elif isinstance(payload, str):
|
||||||
|
# Some decoded payloads (text messages) come as decoded strings.
|
||||||
|
payload_size = len(payload)
|
||||||
|
payload_hex = None # not bytes
|
||||||
|
return {
|
||||||
|
"from_node": packet.get("fromId") or packet.get("from"),
|
||||||
|
"to_node": packet.get("toId") or packet.get("to"),
|
||||||
|
"portnum": portnum,
|
||||||
|
"hop_limit": packet.get("hopLimit"),
|
||||||
|
"want_ack": packet.get("wantAck"),
|
||||||
|
"rx_rssi": packet.get("rxRssi"),
|
||||||
|
"rx_snr": packet.get("rxSnr"),
|
||||||
|
"channel": packet.get("channel"),
|
||||||
|
"id": packet.get("id"),
|
||||||
|
"payload_size": payload_size,
|
||||||
|
"payload_hex_prefix": payload_hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Interface identification ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def interface_label(interface: Any) -> dict[str, Any]:
|
||||||
|
"""Stable identifier for the meshtastic interface that emitted an event.
|
||||||
|
|
||||||
|
Used as the `port`/`role` tag on every recorded row. SerialInterface
|
||||||
|
has `devPath`; TCPInterface has `hostname`+`portNumber`; BLEInterface
|
||||||
|
has `address`. Falls back to the class name when none of those exist.
|
||||||
|
"""
|
||||||
|
if interface is None:
|
||||||
|
return {"port": None, "role": None}
|
||||||
|
dev_path = getattr(interface, "devPath", None)
|
||||||
|
if dev_path:
|
||||||
|
return {"port": str(dev_path), "role": "serial"}
|
||||||
|
hostname = getattr(interface, "hostname", None)
|
||||||
|
if hostname:
|
||||||
|
port_num = getattr(interface, "portNumber", None)
|
||||||
|
endpoint = f"tcp://{hostname}:{port_num}" if port_num else f"tcp://{hostname}"
|
||||||
|
return {"port": endpoint, "role": "tcp"}
|
||||||
|
address = getattr(interface, "address", None)
|
||||||
|
if address:
|
||||||
|
return {"port": str(address), "role": "ble"}
|
||||||
|
return {"port": type(interface).__name__, "role": None}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""Process-global recorder singleton.
|
||||||
|
|
||||||
|
Subscribes once to the meshtastic pubsub fan-out and writes four append-only
|
||||||
|
JSONL streams under `mcp-server/.mtlog/`. The pubsub fan-out is
|
||||||
|
process-global — a single subscription captures every active interface
|
||||||
|
without per-connection bookkeeping.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
logs.jsonl — every `meshtastic.log.line` event (best-effort prefix
|
||||||
|
parsed for level/tag/uptime; raw `line` always preserved)
|
||||||
|
telemetry.jsonl — `meshtastic.receive.telemetry` packets, flattened by
|
||||||
|
variant (device / local / environment / power / etc.)
|
||||||
|
packets.jsonl — every other `meshtastic.receive.*` packet, summarized
|
||||||
|
(portnum, hops, RSSI/SNR, payload size + 64-byte hex)
|
||||||
|
events.jsonl — connection lifecycle, node-DB updates, and manual
|
||||||
|
`mark_event` rows. Lower volume; useful for aligning
|
||||||
|
timelines.
|
||||||
|
|
||||||
|
Pause/resume: `pause()` flips a flag; subscriptions stay registered. The
|
||||||
|
write methods short-circuit when paused, so we don't lose ordering when
|
||||||
|
resumed (we just have a gap). No queueing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import parsers
|
||||||
|
from .rotating import _RotatingJsonl
|
||||||
|
|
||||||
|
_DEFAULT_DIR = Path(__file__).resolve().parents[3] / ".mtlog"
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
"""Singleton write-side of the persistent log capture system."""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: Path | None = None) -> None:
|
||||||
|
self.base_dir = Path(base_dir) if base_dir else _DEFAULT_DIR
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._started = False
|
||||||
|
self._paused = False
|
||||||
|
self._pause_reason: str | None = None
|
||||||
|
self._started_at: float | None = None
|
||||||
|
self._handlers: list[tuple[str, Any]] = []
|
||||||
|
self._files: dict[str, _RotatingJsonl] = {}
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Idempotent. Safe to call from FastMCP app startup."""
|
||||||
|
with self._lock:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._files = {
|
||||||
|
"logs": _RotatingJsonl(self.base_dir / "logs.jsonl"),
|
||||||
|
"telemetry": _RotatingJsonl(self.base_dir / "telemetry.jsonl"),
|
||||||
|
"packets": _RotatingJsonl(self.base_dir / "packets.jsonl"),
|
||||||
|
"events": _RotatingJsonl(self.base_dir / "events.jsonl"),
|
||||||
|
}
|
||||||
|
self._wire_pubsub()
|
||||||
|
self._started = True
|
||||||
|
self._started_at = time.time()
|
||||||
|
# Write the recorder_start marker after the initialization block.
|
||||||
|
# `_write_event()` re-checks recorder state via `_files_snapshot()`,
|
||||||
|
# so keeping this out of the setup block avoids nested lifecycle work.
|
||||||
|
self._write_event(kind="recorder_start", label="recorder_started")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if not self._started:
|
||||||
|
return
|
||||||
|
self._unwire_pubsub()
|
||||||
|
for f in self._files.values():
|
||||||
|
f.close()
|
||||||
|
self._files = {}
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def pause(self, reason: str | None = None) -> None:
|
||||||
|
# Write the pause marker BEFORE flipping the flag — `_write_event`
|
||||||
|
# short-circuits when paused, so the order matters for this event
|
||||||
|
# to actually land in events.jsonl.
|
||||||
|
self._write_event(
|
||||||
|
kind="recorder_pause",
|
||||||
|
label="paused",
|
||||||
|
note=reason,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._paused = True
|
||||||
|
self._pause_reason = reason
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
# Mirror of `pause()`: clear the flag first, then write the marker
|
||||||
|
# so it isn't suppressed by the still-paused short-circuit.
|
||||||
|
with self._lock:
|
||||||
|
self._paused = False
|
||||||
|
self._pause_reason = None
|
||||||
|
self._write_event(kind="recorder_resume", label="resumed")
|
||||||
|
|
||||||
|
# -- pubsub wiring ------------------------------------------------
|
||||||
|
|
||||||
|
def _wire_pubsub(self) -> None:
|
||||||
|
from pubsub import pub # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
# Subscribers — one per topic. Each pubsub publisher sends
|
||||||
|
# keyword args matching its handler's signature; pubsub
|
||||||
|
# introspects the function signature to route args.
|
||||||
|
bindings = [
|
||||||
|
("meshtastic.log.line", self._on_log_line),
|
||||||
|
("meshtastic.serial.line", self._on_serial_line),
|
||||||
|
("meshtastic.receive", self._on_receive),
|
||||||
|
("meshtastic.receive.telemetry", self._on_telemetry),
|
||||||
|
("meshtastic.connection.established", self._on_connection_established),
|
||||||
|
("meshtastic.connection.lost", self._on_connection_lost),
|
||||||
|
("meshtastic.node.updated", self._on_node_updated),
|
||||||
|
]
|
||||||
|
for topic, handler in bindings:
|
||||||
|
try:
|
||||||
|
pub.subscribe(handler, topic)
|
||||||
|
self._handlers.append((topic, handler))
|
||||||
|
except Exception as exc:
|
||||||
|
# If pubsub refuses one binding (signature mismatch on
|
||||||
|
# an old lib version), log it and keep the rest.
|
||||||
|
log.warning("Recorder failed to subscribe to %s: %s", topic, exc)
|
||||||
|
|
||||||
|
def _unwire_pubsub(self) -> None:
|
||||||
|
from pubsub import pub # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
for topic, handler in self._handlers:
|
||||||
|
try:
|
||||||
|
pub.unsubscribe(handler, topic)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._handlers.clear()
|
||||||
|
|
||||||
|
# -- handlers -----------------------------------------------------
|
||||||
|
#
|
||||||
|
# Pubsub callbacks must never raise. Every handler is wrapped in a
|
||||||
|
# try/except that swallows so a bug here can't take down the
|
||||||
|
# SerialInterface receive thread.
|
||||||
|
#
|
||||||
|
# Threading: handlers fire on whatever thread the meshtastic library
|
||||||
|
# dispatches from (varies by interface), while `stop()` clears
|
||||||
|
# `self._files` under `self._lock`. We snapshot `_files` under the
|
||||||
|
# lock at the top of each handler so a concurrent stop can't
|
||||||
|
# KeyError us mid-write. The actual file write goes through
|
||||||
|
# `_RotatingJsonl` which has its own lock.
|
||||||
|
|
||||||
|
def _files_snapshot(self) -> dict[str, _RotatingJsonl] | None:
|
||||||
|
"""Atomic-ish view of `self._files`. Returns None when the recorder
|
||||||
|
is paused or stopped, so handlers can early-exit cleanly without
|
||||||
|
racing `stop()`'s clear."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._started or self._paused:
|
||||||
|
return None
|
||||||
|
return dict(self._files)
|
||||||
|
|
||||||
|
def _on_log_line(self, line: str, interface: Any = None) -> None:
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
parsed = parsers.parse_log_line(str(line))
|
||||||
|
ts = time.time()
|
||||||
|
record: dict[str, Any] = {
|
||||||
|
"ts": ts,
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"level": parsed.get("level"),
|
||||||
|
"tag": parsed.get("tag"),
|
||||||
|
"uptime_s": parsed.get("uptime_s"),
|
||||||
|
"line": parsed["line"],
|
||||||
|
}
|
||||||
|
# DEBUG_HEAP enrichments (only present when the firmware
|
||||||
|
# was built with -DDEBUG_HEAP=1). Surface as first-class
|
||||||
|
# fields so logs_window can grep/filter on them and so
|
||||||
|
# heap_free synthesizes a telemetry point below.
|
||||||
|
if "heap_free" in parsed:
|
||||||
|
record["heap_free"] = parsed["heap_free"]
|
||||||
|
if "heap_total" in parsed:
|
||||||
|
record["heap_total"] = parsed["heap_total"]
|
||||||
|
if "heap_delta" in parsed:
|
||||||
|
record["heap_delta"] = parsed["heap_delta"]
|
||||||
|
heap_event = parsed.get("heap_event")
|
||||||
|
if heap_event:
|
||||||
|
record["heap_event"] = heap_event
|
||||||
|
files["logs"].write(record)
|
||||||
|
|
||||||
|
# If the line carried a heap snapshot, also write it as a
|
||||||
|
# synthesized LocalStats-shaped row so telemetry_timeline
|
||||||
|
# picks it up at log cadence (much higher resolution than
|
||||||
|
# the ~60 s LocalStats packet). Tagged source=debug_heap so
|
||||||
|
# consumers can filter if mixing scales is unwanted.
|
||||||
|
heap_free = parsed.get("heap_free")
|
||||||
|
if isinstance(heap_free, int):
|
||||||
|
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
|
||||||
|
heap_total = parsed.get("heap_total")
|
||||||
|
if isinstance(heap_total, int):
|
||||||
|
fields["heap_total_bytes"] = heap_total
|
||||||
|
files["telemetry"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"from_node": None,
|
||||||
|
"variant": "local",
|
||||||
|
"fields": fields,
|
||||||
|
"source": "debug_heap",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_serial_line(self, line: str, port: str | None = None) -> None:
|
||||||
|
"""Text-mode passive tap. Fired from `serial_session._drain` when a
|
||||||
|
`pio device monitor` subprocess is running.
|
||||||
|
|
||||||
|
Same parse + heap-synthesis path as `_on_log_line`, but receives
|
||||||
|
the raw text-formatted line (full level/clock/uptime/thread/`[heap N]`/
|
||||||
|
body). On DEBUG_HEAP builds in text mode this gives us per-log-line
|
||||||
|
heap data — far higher cadence than LocalStats, and works without
|
||||||
|
protobuf API mode (no SerialInterface required).
|
||||||
|
"""
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parsed = parsers.parse_log_line(str(line))
|
||||||
|
ts = time.time()
|
||||||
|
record: dict[str, Any] = {
|
||||||
|
"ts": ts,
|
||||||
|
"port": port,
|
||||||
|
"role": "serial_session",
|
||||||
|
"level": parsed.get("level"),
|
||||||
|
"tag": parsed.get("tag"),
|
||||||
|
"uptime_s": parsed.get("uptime_s"),
|
||||||
|
"line": parsed["line"],
|
||||||
|
}
|
||||||
|
if "heap_free" in parsed:
|
||||||
|
record["heap_free"] = parsed["heap_free"]
|
||||||
|
if "heap_total" in parsed:
|
||||||
|
record["heap_total"] = parsed["heap_total"]
|
||||||
|
if "heap_delta" in parsed:
|
||||||
|
record["heap_delta"] = parsed["heap_delta"]
|
||||||
|
heap_event = parsed.get("heap_event")
|
||||||
|
if heap_event:
|
||||||
|
record["heap_event"] = heap_event
|
||||||
|
files["logs"].write(record)
|
||||||
|
|
||||||
|
# Synthesize a heap_free telemetry sample whenever the line
|
||||||
|
# carries one — same logic as _on_log_line, tagged source so
|
||||||
|
# consumers can distinguish text-mode tap from protobuf path.
|
||||||
|
heap_free = parsed.get("heap_free")
|
||||||
|
if isinstance(heap_free, int):
|
||||||
|
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
|
||||||
|
heap_total = parsed.get("heap_total")
|
||||||
|
if isinstance(heap_total, int):
|
||||||
|
fields["heap_total_bytes"] = heap_total
|
||||||
|
files["telemetry"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"port": port,
|
||||||
|
"role": "serial_session",
|
||||||
|
"from_node": None,
|
||||||
|
"variant": "local",
|
||||||
|
"fields": fields,
|
||||||
|
"source": "debug_heap_serial",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_telemetry(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
extracted = parsers.extract_telemetry(packet)
|
||||||
|
if extracted is None:
|
||||||
|
# Couldn't extract a known variant — fall through to the
|
||||||
|
# generic `_on_receive` path, which will still fire for
|
||||||
|
# this packet via the parent topic.
|
||||||
|
return
|
||||||
|
record = {
|
||||||
|
"ts": time.time(),
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"from_node": packet.get("fromId") or packet.get("from"),
|
||||||
|
"variant": extracted["variant"],
|
||||||
|
"fields": extracted["fields"],
|
||||||
|
"device_time": extracted.get("time"),
|
||||||
|
}
|
||||||
|
files["telemetry"].write(record)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_receive(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||||
|
# Generic-receive fires for EVERY packet. Telemetry packets get
|
||||||
|
# recorded twice (here and in _on_telemetry) — that's intentional:
|
||||||
|
# packets.jsonl is the universal record, telemetry.jsonl is the
|
||||||
|
# structured timeseries view.
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
summary = parsers.summarize_packet(packet)
|
||||||
|
record = {
|
||||||
|
"ts": time.time(),
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
**summary,
|
||||||
|
}
|
||||||
|
files["packets"].write(record)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_connection_established(self, interface: Any = None) -> None:
|
||||||
|
self._write_event(
|
||||||
|
kind="connection_established",
|
||||||
|
interface=interface,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_connection_lost(self, interface: Any = None) -> None:
|
||||||
|
self._write_event(
|
||||||
|
kind="connection_lost",
|
||||||
|
interface=interface,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_node_updated(
|
||||||
|
self, node: dict[str, Any] | None = None, interface: Any = None
|
||||||
|
) -> None:
|
||||||
|
# Lower-volume than packets but informative — node ID, hops away,
|
||||||
|
# last heard. Skip the user dict if absent.
|
||||||
|
try:
|
||||||
|
user = (node or {}).get("user") if isinstance(node, dict) else None
|
||||||
|
self._write_event(
|
||||||
|
kind="node_updated",
|
||||||
|
interface=interface,
|
||||||
|
data={
|
||||||
|
"num": (node or {}).get("num"),
|
||||||
|
"id": (user or {}).get("id"),
|
||||||
|
"short": (user or {}).get("shortName"),
|
||||||
|
"long": (user or {}).get("longName"),
|
||||||
|
"hops_away": (node or {}).get("hopsAway"),
|
||||||
|
"snr": (node or {}).get("snr"),
|
||||||
|
"last_heard": (node or {}).get("lastHeard"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- public write helpers -----------------------------------------
|
||||||
|
|
||||||
|
def mark_event(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
note: str | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""User-facing marker. Writes to events.jsonl AND emits a
|
||||||
|
synthetic logs.jsonl row tagged level=MARK so timelines align.
|
||||||
|
"""
|
||||||
|
ts = self._write_event(kind="mark", label=label, note=note, data=data)
|
||||||
|
# Mirror into logs so a single logs_window grep finds it.
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is not None:
|
||||||
|
try:
|
||||||
|
files["logs"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"port": None,
|
||||||
|
"role": "marker",
|
||||||
|
"level": "MARK",
|
||||||
|
"tag": "mark_event",
|
||||||
|
"line": f"[mark] {label}" + (f" — {note}" if note else ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ts": ts, "label": label}
|
||||||
|
|
||||||
|
def _write_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
label: str | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
interface: Any = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> float:
|
||||||
|
ts = time.time()
|
||||||
|
# Lifecycle markers (recorder_start, recorder_pause, recorder_resume)
|
||||||
|
# arrive at choreographed moments — `pause()` writes BEFORE flipping
|
||||||
|
# the flag and `resume()` writes AFTER clearing it, so those calls
|
||||||
|
# see _paused=False here. Other event kinds short-circuit when
|
||||||
|
# paused via the snapshot guard below.
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return ts
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
files["events"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"kind": kind,
|
||||||
|
"label": label,
|
||||||
|
"note": note,
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ts
|
||||||
|
|
||||||
|
# -- introspection ------------------------------------------------
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"running": self._started,
|
||||||
|
"paused": self._paused,
|
||||||
|
"pause_reason": self._pause_reason,
|
||||||
|
"started_at": self._started_at,
|
||||||
|
"base_dir": str(self.base_dir),
|
||||||
|
"files": {name: f.status() for name, f in self._files.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
def force_rotate_all(self) -> dict[str, Any]:
|
||||||
|
"""Test/admin hook: rotate every stream right now."""
|
||||||
|
with self._lock:
|
||||||
|
files = list(self._files.values())
|
||||||
|
for f in files:
|
||||||
|
f.force_rotate()
|
||||||
|
# `status()` re-acquires `self._lock`; release before calling it.
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
|
||||||
|
# -- module-level singleton accessor ------------------------------------
|
||||||
|
|
||||||
|
_INSTANCE_LOCK = threading.Lock()
|
||||||
|
_INSTANCE: Recorder | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_recorder() -> Recorder:
|
||||||
|
"""Return the process-global Recorder. Created on first call.
|
||||||
|
|
||||||
|
Honors `MESHTASTIC_MCP_LOG_DIR` env var for the base directory
|
||||||
|
(used by tests to redirect to a tmpdir).
|
||||||
|
"""
|
||||||
|
global _INSTANCE
|
||||||
|
with _INSTANCE_LOCK:
|
||||||
|
if _INSTANCE is None:
|
||||||
|
override = os.environ.get("MESHTASTIC_MCP_LOG_DIR")
|
||||||
|
base = Path(override) if override else None
|
||||||
|
_INSTANCE = Recorder(base_dir=base)
|
||||||
|
return _INSTANCE
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Append-only JSONL writer with size-capped rotation.
|
||||||
|
|
||||||
|
A `_RotatingJsonl` owns one live `.jsonl` file. Writes are line-delimited
|
||||||
|
JSON objects (one row per call). When the live file exceeds `max_bytes`,
|
||||||
|
it is closed, gzipped to `<name>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`,
|
||||||
|
and the live file resets to empty. Old archives past `keep_archives` are
|
||||||
|
unlinked oldest-first.
|
||||||
|
|
||||||
|
Size check is amortized — `os.fstat` runs every `check_every` writes,
|
||||||
|
not per-write, so the hot path stays at one `fh.write` + one `fh.flush`.
|
||||||
|
|
||||||
|
Threading: every public method acquires `self._lock`. The recorder runs
|
||||||
|
several pubsub handlers on whatever thread the meshtastic library
|
||||||
|
dispatches from (varies by interface), and queries from MCP tool calls
|
||||||
|
arrive on the FastMCP request thread, so this lock is not optional.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class _RotatingJsonl:
|
||||||
|
"""Append-only JSONL with size rotation. Thread-safe."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
max_bytes: int = 100 * 1024 * 1024,
|
||||||
|
keep_archives: int = 5,
|
||||||
|
check_every: int = 1000,
|
||||||
|
) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
self.keep_archives = keep_archives
|
||||||
|
self.check_every = check_every
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._fh: Any = None
|
||||||
|
self._writes_since_check = 0
|
||||||
|
self._rotations = 0
|
||||||
|
self._lines_written = 0
|
||||||
|
self._last_ts: float | None = None
|
||||||
|
self._open()
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._fh = self.path.open("a", encoding="utf-8")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._fh is not None:
|
||||||
|
try:
|
||||||
|
self._fh.close()
|
||||||
|
finally:
|
||||||
|
self._fh = None
|
||||||
|
|
||||||
|
# -- write --------------------------------------------------------
|
||||||
|
|
||||||
|
def write(self, record: dict[str, Any]) -> None:
|
||||||
|
"""Append one JSON object as a line. Triggers rotation if oversized."""
|
||||||
|
line = json.dumps(record, separators=(",", ":"), default=str) + "\n"
|
||||||
|
with self._lock:
|
||||||
|
if self._fh is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._fh.write(line)
|
||||||
|
self._fh.flush()
|
||||||
|
except Exception:
|
||||||
|
# Best-effort: a failed write must not crash the pubsub
|
||||||
|
# handler. Caller has no way to react anyway.
|
||||||
|
return
|
||||||
|
self._lines_written += 1
|
||||||
|
ts = record.get("ts")
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
self._last_ts = float(ts)
|
||||||
|
self._writes_since_check += 1
|
||||||
|
if self._writes_since_check >= self.check_every:
|
||||||
|
self._writes_since_check = 0
|
||||||
|
self._maybe_rotate()
|
||||||
|
|
||||||
|
# -- rotation -----------------------------------------------------
|
||||||
|
|
||||||
|
def _maybe_rotate(self) -> None:
|
||||||
|
# Caller holds self._lock.
|
||||||
|
try:
|
||||||
|
size = os.fstat(self._fh.fileno()).st_size
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if size < self.max_bytes:
|
||||||
|
return
|
||||||
|
self._rotate_locked()
|
||||||
|
|
||||||
|
def _rotate_locked(self) -> None:
|
||||||
|
# Close, gzip-rename, reopen empty, prune oldest archives.
|
||||||
|
try:
|
||||||
|
self._fh.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._fh = None
|
||||||
|
# Microsecond-resolution timestamp + per-instance counter so back-
|
||||||
|
# to-back rotations (small max_bytes, repeated `force_rotate()`,
|
||||||
|
# or chatty test loops) get unique archive filenames. The lex
|
||||||
|
# sort order of `YYYYMMDD-HHMMSS-uuuuuu-NNNNN` is chronological,
|
||||||
|
# which `_prune_archives()` and `log_query._iter_jsonl()` both
|
||||||
|
# rely on.
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
||||||
|
archive = self.path.with_suffix(f".{stamp}-{self._rotations:05d}.jsonl.gz")
|
||||||
|
try:
|
||||||
|
with self.path.open("rb") as src, gzip.open(archive, "wb") as dst:
|
||||||
|
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
||||||
|
self.path.unlink()
|
||||||
|
except Exception:
|
||||||
|
# Rotation is best-effort. If gzip fails, leave the file
|
||||||
|
# in place and re-open it; we'll try again next check.
|
||||||
|
pass
|
||||||
|
self._open()
|
||||||
|
self._rotations += 1
|
||||||
|
self._prune_archives()
|
||||||
|
|
||||||
|
def _prune_archives(self) -> None:
|
||||||
|
# Match siblings of self.path.name with `.jsonl.gz` suffix.
|
||||||
|
prefix = self.path.stem # "logs" for "logs.jsonl"
|
||||||
|
# Archive filenames are already lexicographically chronological.
|
||||||
|
# Prune by name, not mtime, so copied/restored files don't reorder.
|
||||||
|
archives = sorted(self.path.parent.glob(f"{prefix}.*.jsonl.gz"))
|
||||||
|
excess = len(archives) - self.keep_archives
|
||||||
|
for old in archives[: max(0, excess)]:
|
||||||
|
try:
|
||||||
|
old.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def force_rotate(self) -> None:
|
||||||
|
"""Test/admin hook: rotate immediately regardless of size."""
|
||||||
|
with self._lock:
|
||||||
|
if self._fh is not None:
|
||||||
|
self._rotate_locked()
|
||||||
|
|
||||||
|
# -- introspection ------------------------------------------------
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
size = os.fstat(self._fh.fileno()).st_size if self._fh else 0
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
return {
|
||||||
|
"path": str(self.path),
|
||||||
|
"size": size,
|
||||||
|
"lines": self._lines_written,
|
||||||
|
"last_ts": self._last_ts,
|
||||||
|
"rotations": self._rotations,
|
||||||
|
}
|
||||||
@@ -46,7 +46,23 @@ class SerialSession:
|
|||||||
|
|
||||||
|
|
||||||
def _drain(session: SerialSession) -> None:
|
def _drain(session: SerialSession) -> None:
|
||||||
"""Reader thread: line-by-line pull stdout into buffer."""
|
"""Reader thread: line-by-line pull stdout into buffer.
|
||||||
|
|
||||||
|
Each line is also published to the `meshtastic.serial.line` pubsub
|
||||||
|
topic so the persistent recorder can capture it without holding its
|
||||||
|
own port. This is the text-mode tap path: when no SerialInterface is
|
||||||
|
open, the firmware emits full formatted lines (level + clock + uptime
|
||||||
|
+ thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we
|
||||||
|
fan them out to whoever is listening. Pubsub is best-effort —
|
||||||
|
publish failures must never block the reader.
|
||||||
|
"""
|
||||||
|
# Lazy import: pubsub isn't required just to import this module
|
||||||
|
# (e.g., during static analysis), and we want a clean test surface.
|
||||||
|
try:
|
||||||
|
from pubsub import pub # type: ignore[import-untyped]
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
pub = None
|
||||||
|
|
||||||
assert session.proc.stdout is not None
|
assert session.proc.stdout is not None
|
||||||
try:
|
try:
|
||||||
for line in session.proc.stdout:
|
for line in session.proc.stdout:
|
||||||
@@ -54,6 +70,16 @@ def _drain(session: SerialSession) -> None:
|
|||||||
with session.lock:
|
with session.lock:
|
||||||
session.buffer.append(line_stripped)
|
session.buffer.append(line_stripped)
|
||||||
session.total_lines += 1
|
session.total_lines += 1
|
||||||
|
if pub is not None:
|
||||||
|
try:
|
||||||
|
pub.sendMessage(
|
||||||
|
"meshtastic.serial.line",
|
||||||
|
line=line_stripped,
|
||||||
|
port=session.port,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# A subscriber raising must not break the reader.
|
||||||
|
pass
|
||||||
except Exception: # pragma: no cover - defensive
|
except Exception: # pragma: no cover - defensive
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ etc.). Business logic does not live here.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
@@ -14,17 +15,38 @@ from . import (
|
|||||||
admin,
|
admin,
|
||||||
boards,
|
boards,
|
||||||
devices,
|
devices,
|
||||||
|
fixtures,
|
||||||
flash,
|
flash,
|
||||||
hw_tools,
|
hw_tools,
|
||||||
info,
|
info,
|
||||||
|
log_query,
|
||||||
registry,
|
registry,
|
||||||
serial_session,
|
serial_session,
|
||||||
)
|
)
|
||||||
from . import userprefs as userprefs_mod
|
from . import userprefs as userprefs_mod
|
||||||
|
from .recorder import get_recorder
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastMCP("meshtastic-mcp")
|
app = FastMCP("meshtastic-mcp")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_recorder() -> None:
|
||||||
|
# Persistent device-log capture. Starts on first import — pubsub fan-out
|
||||||
|
# is process-global, so subscribing here captures every active interface
|
||||||
|
# (whether opened by an MCP tool, a pytest fixture, or a serial_session).
|
||||||
|
# Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py
|
||||||
|
# for the full design. Recorder startup is best-effort: an unwritable
|
||||||
|
# log dir or pubsub mismatch should not take the MCP server down.
|
||||||
|
try:
|
||||||
|
get_recorder().start()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to start persistent recorder: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
_start_recorder()
|
||||||
|
|
||||||
|
|
||||||
# ---------- Discovery & metadata ------------------------------------------
|
# ---------- Discovery & metadata ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +97,7 @@ def build(
|
|||||||
env: str,
|
env: str,
|
||||||
with_manifest: bool = True,
|
with_manifest: bool = True,
|
||||||
userprefs: dict[str, Any] | None = None,
|
userprefs: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build firmware for one env via `pio run -e <env>`.
|
"""Build firmware for one env via `pio run -e <env>`.
|
||||||
|
|
||||||
@@ -86,8 +109,21 @@ def build(
|
|||||||
build via userPrefs.jsonc injection. The file is restored after the build
|
build via userPrefs.jsonc injection. The file is restored after the build
|
||||||
completes. Use `userprefs_manifest` to discover available keys. Use
|
completes. Use `userprefs_manifest` to discover available keys. Use
|
||||||
`userprefs_set` for persistent changes.
|
`userprefs_set` for persistent changes.
|
||||||
|
|
||||||
|
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for this build
|
||||||
|
only, injected via `PLATFORMIO_BUILD_FLAGS`. Common pattern:
|
||||||
|
`build_flags={"DEBUG_HEAP": 1}` enables per-thread leak detection + a
|
||||||
|
`[heap N]` prefix on every log line. The recorder picks the prefix up
|
||||||
|
automatically and synthesizes a high-resolution heap timeline that
|
||||||
|
`telemetry_timeline(field="free_heap")` can read alongside the normal
|
||||||
|
~60 s LocalStats packets. Pair with `/leakhunt` for classification.
|
||||||
"""
|
"""
|
||||||
return flash.build(env, with_manifest=with_manifest, userprefs_overrides=userprefs)
|
return flash.build(
|
||||||
|
env,
|
||||||
|
with_manifest=with_manifest,
|
||||||
|
userprefs_overrides=userprefs,
|
||||||
|
build_flags=build_flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
@@ -105,6 +141,7 @@ def pio_flash(
|
|||||||
port: str,
|
port: str,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
userprefs: dict[str, Any] | None = None,
|
userprefs: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
|
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
|
||||||
|
|
||||||
@@ -114,8 +151,19 @@ def pio_flash(
|
|||||||
|
|
||||||
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
|
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
|
||||||
build via userPrefs.jsonc injection; restored after upload.
|
build via userPrefs.jsonc injection; restored after upload.
|
||||||
|
|
||||||
|
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for the
|
||||||
|
rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags
|
||||||
|
to actually land in the uploaded firmware — without it, the implicit
|
||||||
|
rebuild relinks without the env var and silently drops them.
|
||||||
"""
|
"""
|
||||||
return flash.flash(env, port, confirm=confirm, userprefs_overrides=userprefs)
|
return flash.flash(
|
||||||
|
env,
|
||||||
|
port,
|
||||||
|
confirm=confirm,
|
||||||
|
userprefs_overrides=userprefs,
|
||||||
|
build_flags=build_flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
@@ -734,3 +782,227 @@ def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
|
|||||||
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
||||||
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
|
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
|
||||||
return hw_tools.picotool_raw(args, confirm=confirm)
|
return hw_tools.picotool_raw(args, confirm=confirm)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Persistent device-log capture (recorder) ----------------------
|
||||||
|
#
|
||||||
|
# The recorder is autouse — it starts at server import and continuously
|
||||||
|
# writes every meshtastic pubsub event to JSONL files under .mtlog/. These
|
||||||
|
# tools are query-only over those files, plus a few lifecycle controls.
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def logs_window(
|
||||||
|
start: str = "-15m",
|
||||||
|
end: str = "now",
|
||||||
|
grep: str | None = None,
|
||||||
|
level: str | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
port: str | None = None,
|
||||||
|
max_lines: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recent firmware log lines from the persistent recorder.
|
||||||
|
|
||||||
|
Filters by time window, regex over the line, level (single or
|
||||||
|
pipe-separated set like "WARN|ERROR|CRIT"), thread-name tag, and
|
||||||
|
interface port. Returns up to max_lines most-recent matches.
|
||||||
|
|
||||||
|
Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601.
|
||||||
|
|
||||||
|
Note: lines arriving via the LogRecord protobuf path (when
|
||||||
|
set_debug_log_api(True) is on) come without level prefix — the
|
||||||
|
meshtastic Python lib drops record.level before fan-out. For those,
|
||||||
|
`level` filter won't match; use `grep` instead.
|
||||||
|
"""
|
||||||
|
return log_query.logs_window(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
grep=grep,
|
||||||
|
level=level,
|
||||||
|
tag=tag,
|
||||||
|
port=port,
|
||||||
|
max_lines=max_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def telemetry_timeline(
|
||||||
|
window: str = "1h",
|
||||||
|
variant: str = "local",
|
||||||
|
field: str = "free_heap",
|
||||||
|
port: str | None = None,
|
||||||
|
max_points: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Time series of one telemetry field, downsampled to <= max_points.
|
||||||
|
|
||||||
|
`variant` ∈ device, local, environment, power, airQuality, health, host.
|
||||||
|
`field` accepts snake_case or camelCase; common aliases (free_heap ↔
|
||||||
|
heap_free_bytes) are normalized.
|
||||||
|
|
||||||
|
Returns slope_per_min (linear-regression slope, units/minute) so a
|
||||||
|
leak detector can read one number — negative slope on free_heap over
|
||||||
|
a long window indicates a real leak.
|
||||||
|
|
||||||
|
LocalStats variant ("local") cadence is ~60 s (whatever the device's
|
||||||
|
`device_update_interval` is set to), so a 1 h window gives ~60 raw
|
||||||
|
points. Bucket-mean downsampling preserves shape.
|
||||||
|
"""
|
||||||
|
return log_query.telemetry_timeline(
|
||||||
|
window=window,
|
||||||
|
variant=variant,
|
||||||
|
field=field,
|
||||||
|
port=port,
|
||||||
|
max_points=max_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def packets_window(
|
||||||
|
start: str = "-5m",
|
||||||
|
end: str = "now",
|
||||||
|
portnum: str | None = None,
|
||||||
|
from_node: str | None = None,
|
||||||
|
to_node: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recent mesh packets recorded by the recorder.
|
||||||
|
|
||||||
|
Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload
|
||||||
|
size + first 64 bytes hex) — full payload bytes are not stored.
|
||||||
|
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
|
||||||
|
"""
|
||||||
|
return log_query.packets_window(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
portnum=portnum,
|
||||||
|
from_node=from_node,
|
||||||
|
to_node=to_node,
|
||||||
|
max=max,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def events_window(
|
||||||
|
start: str = "-1h",
|
||||||
|
end: str = "now",
|
||||||
|
kind: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return recorder events: connection lifecycle, node updates, and `mark_event` markers.
|
||||||
|
|
||||||
|
`kind` ∈ recorder_start, recorder_pause, recorder_resume,
|
||||||
|
connection_established, connection_lost, node_updated, mark.
|
||||||
|
Pipe-separated sets ("connection_lost|connection_established") work.
|
||||||
|
"""
|
||||||
|
return log_query.events_window(start=start, end=end, kind=kind, max=max)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def mark_event(
|
||||||
|
label: str,
|
||||||
|
note: str | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Drop a named marker into events.jsonl AND logs.jsonl.
|
||||||
|
|
||||||
|
Useful for aligning a timeline around a known stimulus: call before
|
||||||
|
and after a stress workload, then query telemetry_timeline /
|
||||||
|
logs_window with the markers' timestamps as bounds.
|
||||||
|
|
||||||
|
The marker also lands in logs.jsonl with level=MARK so a single
|
||||||
|
grep over logs picks it up.
|
||||||
|
"""
|
||||||
|
return get_recorder().mark_event(label=label, note=note, data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_status() -> dict[str, Any]:
|
||||||
|
"""Return recorder runtime info: running, paused, file sizes, last_ts per stream.
|
||||||
|
|
||||||
|
Use this to sanity-check that capture is working before you trust a
|
||||||
|
`logs_window` / `telemetry_timeline` result.
|
||||||
|
"""
|
||||||
|
return get_recorder().status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Pause writes to all four streams. Pubsub subscriptions stay active —
|
||||||
|
we just drop events on the floor while paused. Resume with `recorder_resume`.
|
||||||
|
|
||||||
|
Use when capturing a known-good baseline that you don't want to
|
||||||
|
pollute with pre-test noise. Default state is recording; this is
|
||||||
|
rarely needed.
|
||||||
|
"""
|
||||||
|
get_recorder().pause(reason=reason)
|
||||||
|
return {"ok": True, "paused": True, "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_resume() -> dict[str, Any]:
|
||||||
|
"""Resume writes after `recorder_pause`. No-op if already running."""
|
||||||
|
get_recorder().resume()
|
||||||
|
return {"ok": True, "paused": False}
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_export(
|
||||||
|
start: str,
|
||||||
|
end: str,
|
||||||
|
dest_dir: str,
|
||||||
|
streams: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Bundle a slice of the recorder's streams into `dest_dir`.
|
||||||
|
|
||||||
|
Writes one uncompressed JSONL per requested stream (logs / telemetry /
|
||||||
|
packets / events). Useful for: attaching to a bug report, feeding a
|
||||||
|
notebook, or backfilling Datadog after the fact.
|
||||||
|
"""
|
||||||
|
return log_query.export(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
dest_dir=dest_dir,
|
||||||
|
streams=streams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Fixture / test-data push --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def push_fake_nodedb(
|
||||||
|
size: int,
|
||||||
|
target: str = "portduino",
|
||||||
|
port: str | None = None,
|
||||||
|
portduino_config: str = "default",
|
||||||
|
backup_existing: bool = True,
|
||||||
|
confirm: bool = False,
|
||||||
|
reboot_after: bool = True,
|
||||||
|
custom_seed_jsonl: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
|
||||||
|
|
||||||
|
Two transports:
|
||||||
|
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
|
||||||
|
Fast, no device connection needed.
|
||||||
|
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
|
||||||
|
Requires `port` + `confirm=True`. Triggers a reboot
|
||||||
|
so loadFromDisk picks up the new file at next boot.
|
||||||
|
|
||||||
|
Compiles a fresh-timestamp proto from the committed JSONL seed under
|
||||||
|
test/fixtures/nodedb/seed_v25_<N>.jsonl each invocation, so the loaded
|
||||||
|
NodeDB always looks "recent" to the connecting phone. Structural data
|
||||||
|
(names, IDs, positions, telemetries) is deterministic per the seed.
|
||||||
|
|
||||||
|
Override the JSONL via `custom_seed_jsonl` to push a hand-edited scenario.
|
||||||
|
"""
|
||||||
|
return fixtures.push_fake_nodedb(
|
||||||
|
size=size,
|
||||||
|
target=target, # type: ignore[arg-type]
|
||||||
|
port=port,
|
||||||
|
portduino_config=portduino_config,
|
||||||
|
backup_existing=backup_existing,
|
||||||
|
confirm=confirm,
|
||||||
|
reboot_after=reboot_after,
|
||||||
|
custom_seed_jsonl=custom_seed_jsonl,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Unit tests for the `build_flags` injection on `flash.build()`.
|
||||||
|
|
||||||
|
We don't actually run pio here — too slow, requires hardware-aware envs.
|
||||||
|
We test the translation layer (`_build_flags_env`) and that the env vars
|
||||||
|
are threaded through pio.run correctly via mock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from meshtastic_mcp import flash, pio
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFlagsEnv:
|
||||||
|
def test_simple_value(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": 1})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"}
|
||||||
|
|
||||||
|
def test_string_value(self) -> None:
|
||||||
|
out = flash._build_flags_env({"FOO": "bar"})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DFOO=bar"}
|
||||||
|
|
||||||
|
def test_bool_true_is_bare_flag(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": True})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP"}
|
||||||
|
|
||||||
|
def test_bool_false_dropped(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": False, "OTHER": 1})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DOTHER=1"}
|
||||||
|
|
||||||
|
def test_none_dropped(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": None})
|
||||||
|
assert out == {}
|
||||||
|
|
||||||
|
def test_multiple_combined(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": 1, "FOO": "x", "BAR": True})
|
||||||
|
# Order isn't guaranteed in dict iteration, so check membership.
|
||||||
|
flags = out["PLATFORMIO_BUILD_FLAGS"].split()
|
||||||
|
assert set(flags) == {"-DDEBUG_HEAP=1", "-DFOO=x", "-DBAR"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPropagatesFlags:
|
||||||
|
def test_extra_env_passed_to_pio_run(self) -> None:
|
||||||
|
# Mock pio.run so we don't actually invoke pio. Capture extra_env.
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class _StubResult:
|
||||||
|
returncode = 0
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
duration_s = 0.1
|
||||||
|
|
||||||
|
def _stub(args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return _StubResult()
|
||||||
|
|
||||||
|
with patch.object(pio, "run", side_effect=_stub):
|
||||||
|
with patch.object(flash, "_artifacts_for", return_value=[]):
|
||||||
|
out = flash.build(
|
||||||
|
"fake-env",
|
||||||
|
with_manifest=False,
|
||||||
|
build_flags={"DEBUG_HEAP": 1},
|
||||||
|
)
|
||||||
|
assert captured["args"] == ["run", "-e", "fake-env"]
|
||||||
|
assert captured["kwargs"]["extra_env"] == {
|
||||||
|
"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"
|
||||||
|
}
|
||||||
|
assert out["build_flags"] == {"DEBUG_HEAP": 1}
|
||||||
|
|
||||||
|
def test_no_flags_means_no_extra_env(self) -> None:
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class _StubResult:
|
||||||
|
returncode = 0
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
duration_s = 0.1
|
||||||
|
|
||||||
|
def _stub(args, **kwargs):
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return _StubResult()
|
||||||
|
|
||||||
|
with patch.object(pio, "run", side_effect=_stub):
|
||||||
|
with patch.object(flash, "_artifacts_for", return_value=[]):
|
||||||
|
flash.build("fake-env", with_manifest=False)
|
||||||
|
assert captured["kwargs"]["extra_env"] is None
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
|
||||||
|
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
|
||||||
|
|
||||||
|
Lives under tests/unit/ because none of these touch real hardware — they
|
||||||
|
shell out to the bin/ scripts and decode the resulting protobufs in-process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
SEED_GEN = REPO_ROOT / "bin" / "gen-fake-nodedb-seed.py"
|
||||||
|
COMPILE = REPO_ROOT / "bin" / "seed-json-to-proto.py"
|
||||||
|
FIXTURES_DIR = REPO_ROOT / "test" / "fixtures" / "nodedb"
|
||||||
|
|
||||||
|
# Ensure the locally-generated Python protobuf bindings are importable.
|
||||||
|
# These live under `meshtastic_v25` (not `meshtastic`) so they don't shadow
|
||||||
|
# the PyPI `meshtastic` package that the rest of the mcp-server depends on.
|
||||||
|
_BINDINGS_DIR = REPO_ROOT / "bin" / "_generated"
|
||||||
|
if _BINDINGS_DIR.is_dir() and str(_BINDINGS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BINDINGS_DIR))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from meshtastic_v25.deviceonly_pb2 import (
|
||||||
|
NodeDatabase, # type: ignore[import-not-found]
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
NodeDatabase = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_v25_bindings() -> None:
|
||||||
|
if NodeDatabase is None:
|
||||||
|
pytest.skip(
|
||||||
|
"v25 Python protobuf bindings missing; run `./bin/regen-py-protos.sh`."
|
||||||
|
)
|
||||||
|
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
|
||||||
|
pytest.skip(
|
||||||
|
"Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str]) -> None:
|
||||||
|
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seed generator: deterministic for given --seed (no wall-clock dependence).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_seed_generator_is_deterministic(tmp_path: pathlib.Path) -> None:
|
||||||
|
a = tmp_path / "a.jsonl"
|
||||||
|
b = tmp_path / "b.jsonl"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(SEED_GEN),
|
||||||
|
"--count",
|
||||||
|
"100",
|
||||||
|
"--seed",
|
||||||
|
"42",
|
||||||
|
"--out",
|
||||||
|
str(a),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Sleep so any sneaky wall-clock leak in the generator would surface as
|
||||||
|
# a byte diff between the two runs.
|
||||||
|
time.sleep(0.8)
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(SEED_GEN),
|
||||||
|
"--count",
|
||||||
|
"100",
|
||||||
|
"--seed",
|
||||||
|
"42",
|
||||||
|
"--out",
|
||||||
|
str(b),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert a.read_bytes() == b.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_generator_meta_line(tmp_path: pathlib.Path) -> None:
|
||||||
|
out = tmp_path / "seed.jsonl"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(SEED_GEN),
|
||||||
|
"--count",
|
||||||
|
"50",
|
||||||
|
"--seed",
|
||||||
|
"1",
|
||||||
|
"--out",
|
||||||
|
str(out),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines = out.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert len(lines) == 51 # 1 meta + 50 nodes
|
||||||
|
meta = json.loads(lines[0])
|
||||||
|
assert "_meta" in meta
|
||||||
|
assert meta["_meta"]["version"] == 25
|
||||||
|
assert meta["_meta"]["count"] == 50
|
||||||
|
assert meta["_meta"]["seed"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_only_uses_active_hardware_and_roles(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Confirm no deprecated roles + no off-list HW models leak through."""
|
||||||
|
out = tmp_path / "seed.jsonl"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(SEED_GEN),
|
||||||
|
"--count",
|
||||||
|
"500",
|
||||||
|
"--seed",
|
||||||
|
"7",
|
||||||
|
"--out",
|
||||||
|
str(out),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
forbidden_roles = {"ROUTER_CLIENT", "REPEATER"}
|
||||||
|
forbidden_hw = {
|
||||||
|
"TLORA_V1",
|
||||||
|
"TLORA_V2",
|
||||||
|
"TLORA_V1_1P3",
|
||||||
|
"TLORA_V2_1_1P6",
|
||||||
|
"TLORA_V2_1_1P8",
|
||||||
|
"HELTEC_V1",
|
||||||
|
"HELTEC_V2_0",
|
||||||
|
"HELTEC_V2_1",
|
||||||
|
"TBEAM",
|
||||||
|
"TBEAM_V0P7",
|
||||||
|
"NANO_G1",
|
||||||
|
"NANO_G1_EXPLORER",
|
||||||
|
"NANO_G2_ULTRA",
|
||||||
|
"STATION_G1",
|
||||||
|
"STATION_G2",
|
||||||
|
"PORTDUINO",
|
||||||
|
"ANDROID_SIM",
|
||||||
|
"DIY_V1",
|
||||||
|
"LORA_RELAY_V1",
|
||||||
|
"NRF52840_PCA10059",
|
||||||
|
"NRF52_UNKNOWN",
|
||||||
|
"DR_DEV",
|
||||||
|
"GENIEBLOCKS",
|
||||||
|
"M5STACK",
|
||||||
|
"RP2040_LORA",
|
||||||
|
"PPR",
|
||||||
|
}
|
||||||
|
for raw in out.read_text(encoding="utf-8").splitlines()[1:]:
|
||||||
|
node = json.loads(raw)
|
||||||
|
assert node["role"] not in forbidden_roles, f"deprecated role: {node['role']}"
|
||||||
|
assert (
|
||||||
|
node["hw_model"] not in forbidden_hw
|
||||||
|
), f"non-tier-1 HW: {node['hw_model']}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compile step + committed seeds.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.parametrize("size", [250, 500, 1000, 2000])
|
||||||
|
def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path) -> None:
|
||||||
|
_require_v25_bindings()
|
||||||
|
proto = tmp_path / "out.proto"
|
||||||
|
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||||
|
if not jsonl.is_file():
|
||||||
|
pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh")
|
||||||
|
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
|
||||||
|
|
||||||
|
db = NodeDatabase()
|
||||||
|
db.ParseFromString(proto.read_bytes())
|
||||||
|
assert db.version == 25
|
||||||
|
assert len(db.nodes) == size
|
||||||
|
nums = {n.num for n in db.nodes}
|
||||||
|
assert len(nums) == size, "node numbers must be unique"
|
||||||
|
assert all(n.long_name and n.short_name for n in db.nodes)
|
||||||
|
assert all(len(n.long_name) <= 24 for n in db.nodes) # max_size:25 - NUL
|
||||||
|
|
||||||
|
# Coverage sanity (±10pp tolerance for binomial fluctuation).
|
||||||
|
def in_range(actual: int, expected_ratio: float, tol_pp: float = 0.10) -> bool:
|
||||||
|
lo = max(0, int((expected_ratio - tol_pp) * size))
|
||||||
|
hi = min(size, int((expected_ratio + tol_pp) * size))
|
||||||
|
return lo <= actual <= hi
|
||||||
|
|
||||||
|
assert in_range(len(db.positions), 0.85)
|
||||||
|
assert in_range(len(db.telemetry), 0.70)
|
||||||
|
assert in_range(len(db.environment), 0.25)
|
||||||
|
assert in_range(len(db.status), 0.40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Same JSONL compiled twice → identical structure, different timestamps."""
|
||||||
|
_require_v25_bindings()
|
||||||
|
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||||
|
if not jsonl.is_file():
|
||||||
|
pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh")
|
||||||
|
a = tmp_path / "a.proto"
|
||||||
|
b = tmp_path / "b.proto"
|
||||||
|
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
|
||||||
|
time.sleep(1.2)
|
||||||
|
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(b)])
|
||||||
|
|
||||||
|
da = NodeDatabase()
|
||||||
|
db_ = NodeDatabase()
|
||||||
|
da.ParseFromString(a.read_bytes())
|
||||||
|
db_.ParseFromString(b.read_bytes())
|
||||||
|
|
||||||
|
# Zero out timestamp fields and confirm everything else is byte-identical.
|
||||||
|
for d in (da, db_):
|
||||||
|
for n in d.nodes:
|
||||||
|
n.last_heard = 0
|
||||||
|
for p in d.positions:
|
||||||
|
p.position.time = 0
|
||||||
|
assert da.SerializeToString() == db_.SerializeToString()
|
||||||
|
|
||||||
|
# Re-load fresh copies to confirm timestamps actually moved.
|
||||||
|
aa = NodeDatabase()
|
||||||
|
bb = NodeDatabase()
|
||||||
|
aa.ParseFromString(a.read_bytes())
|
||||||
|
bb.ParseFromString(b.read_bytes())
|
||||||
|
aa_max = max(n.last_heard for n in aa.nodes if n.last_heard)
|
||||||
|
bb_max = max(n.last_heard for n in bb.nodes if n.last_heard)
|
||||||
|
assert bb_max >= aa_max
|
||||||
|
assert bb_max - aa_max < 5 # within a few seconds
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_pinned_now_epoch_is_byte_identical(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""With --now-epoch pinned, two compiles produce identical bytes."""
|
||||||
|
_require_v25_bindings()
|
||||||
|
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||||
|
if not jsonl.is_file():
|
||||||
|
pytest.skip("250-node seed not present")
|
||||||
|
a = tmp_path / "a.proto"
|
||||||
|
b = tmp_path / "b.proto"
|
||||||
|
for o in (a, b):
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(COMPILE),
|
||||||
|
"--in",
|
||||||
|
str(jsonl),
|
||||||
|
"--now-epoch",
|
||||||
|
"1700000000",
|
||||||
|
"--out",
|
||||||
|
str(o),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert a.read_bytes() == b.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_timestamps_are_recent(tmp_path: pathlib.Path) -> None:
|
||||||
|
_require_v25_bindings()
|
||||||
|
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||||
|
if not jsonl.is_file():
|
||||||
|
pytest.skip("250-node seed not present")
|
||||||
|
out = tmp_path / "out.proto"
|
||||||
|
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(out)])
|
||||||
|
db = NodeDatabase()
|
||||||
|
db.ParseFromString(out.read_bytes())
|
||||||
|
now = int(time.time())
|
||||||
|
# No timestamp older than 7 days, none in the future.
|
||||||
|
for n in db.nodes:
|
||||||
|
if n.last_heard:
|
||||||
|
assert now - 7 * 86400 <= n.last_heard <= now
|
||||||
|
# At least half should be within the last hour
|
||||||
|
# (matches expovariate(mean=3600s)).
|
||||||
|
recent = sum(1 for n in db.nodes if n.last_heard and n.last_heard >= now - 3600)
|
||||||
|
assert recent >= 0.4 * len(db.nodes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_hand_edit_round_trip(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Edit one JSONL line, recompile, confirm edit appears in the proto."""
|
||||||
|
_require_v25_bindings()
|
||||||
|
src = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||||
|
if not src.is_file():
|
||||||
|
pytest.skip("250-node seed not present")
|
||||||
|
dst = tmp_path / "edited.jsonl"
|
||||||
|
lines = src.read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
# Find a node that already has telemetry so the index relationship is
|
||||||
|
# easy to assert on the other side.
|
||||||
|
edit_idx = None
|
||||||
|
for i, raw in enumerate(lines[1:], start=1):
|
||||||
|
node = json.loads(raw)
|
||||||
|
if node.get("telemetry") is not None:
|
||||||
|
edit_idx = i
|
||||||
|
break
|
||||||
|
assert edit_idx is not None, "expected at least one node with telemetry"
|
||||||
|
|
||||||
|
node = json.loads(lines[edit_idx])
|
||||||
|
target_num = int(node["num"], 16)
|
||||||
|
node["long_name"] = "Hand Edited Node"
|
||||||
|
node["telemetry"] = {
|
||||||
|
"battery_level": 42,
|
||||||
|
"voltage": 3.71,
|
||||||
|
"channel_utilization": 0.0,
|
||||||
|
"air_util_tx": 0.0,
|
||||||
|
"uptime_seconds": 1,
|
||||||
|
}
|
||||||
|
lines[edit_idx] = json.dumps(node, ensure_ascii=False, sort_keys=True)
|
||||||
|
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
out = tmp_path / "out.proto"
|
||||||
|
_run([sys.executable, str(COMPILE), "--in", str(dst), "--out", str(out)])
|
||||||
|
db = NodeDatabase()
|
||||||
|
db.ParseFromString(out.read_bytes())
|
||||||
|
edited = next((n for n in db.nodes if n.num == target_num), None)
|
||||||
|
assert edited is not None
|
||||||
|
assert edited.long_name == "Hand Edited Node"
|
||||||
|
tel = next((t for t in db.telemetry if t.num == target_num), None)
|
||||||
|
assert tel is not None
|
||||||
|
assert tel.device_metrics.battery_level == 42
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Misc smoke checks on the module surface.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_crc16_ccitt_matches_known_vectors() -> None:
|
||||||
|
"""Sanity-check the hand-rolled CRC16-CCITT matches well-known vectors.
|
||||||
|
|
||||||
|
Test vectors from the XModem-CRC spec (init=0, poly=0x1021):
|
||||||
|
crc16("123456789") = 0x31C3
|
||||||
|
crc16("") = 0x0000
|
||||||
|
"""
|
||||||
|
from meshtastic_mcp.fixtures import _crc16_ccitt
|
||||||
|
|
||||||
|
assert _crc16_ccitt(b"") == 0x0000
|
||||||
|
assert _crc16_ccitt(b"123456789") == 0x31C3
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_fake_nodedb_rejects_invalid_size() -> None:
|
||||||
|
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||||
|
|
||||||
|
with pytest.raises(FixtureError, match="size must be one of"):
|
||||||
|
push_fake_nodedb(size=999, target="portduino") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_fake_nodedb_hardware_requires_confirm() -> None:
|
||||||
|
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||||
|
|
||||||
|
with pytest.raises(FixtureError, match="confirm=True"):
|
||||||
|
push_fake_nodedb(size=250, target="hardware", port="/dev/cu.fake")
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_fake_nodedb_hardware_requires_port() -> None:
|
||||||
|
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||||
|
|
||||||
|
with pytest.raises(FixtureError, match="requires a port"):
|
||||||
|
push_fake_nodedb(size=250, target="hardware", confirm=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_fake_nodedb_hardware_rejects_tcp_port() -> None:
|
||||||
|
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||||
|
|
||||||
|
with pytest.raises(FixtureError, match="not supported"):
|
||||||
|
push_fake_nodedb(
|
||||||
|
size=250, target="hardware", confirm=True, port="tcp://localhost:4403"
|
||||||
|
)
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
"""Unit tests for the persistent device-log recorder.
|
||||||
|
|
||||||
|
Hardware-free: drives the Recorder through its `_on_*` handlers with
|
||||||
|
synthetic packet/line dicts, then queries via log_query. Validates
|
||||||
|
prefix parsing, telemetry variant dispatch, marker round-trip, time
|
||||||
|
window filtering, downsampling, slope estimation, and gzip rotation
|
||||||
|
+ archive pruning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pubsub
|
||||||
|
import pytest
|
||||||
|
from meshtastic_mcp import log_query
|
||||||
|
from meshtastic_mcp.recorder.parsers import (
|
||||||
|
extract_telemetry,
|
||||||
|
interface_label,
|
||||||
|
parse_log_line,
|
||||||
|
summarize_packet,
|
||||||
|
)
|
||||||
|
from meshtastic_mcp.recorder.recorder import Recorder
|
||||||
|
from meshtastic_mcp.recorder.rotating import _RotatingJsonl
|
||||||
|
|
||||||
|
# -- isolation: every test gets a fresh Recorder + tmp dir -----------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recorder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Recorder:
|
||||||
|
# Redirect both the Recorder and the module-level singleton lookup
|
||||||
|
# to the same tmp dir so log_query queries the same files we write.
|
||||||
|
monkeypatch.setenv("MESHTASTIC_MCP_LOG_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"meshtastic_mcp.recorder.recorder._INSTANCE", None, raising=False
|
||||||
|
)
|
||||||
|
r = Recorder(base_dir=tmp_path)
|
||||||
|
r.start()
|
||||||
|
monkeypatch.setattr("meshtastic_mcp.recorder.recorder._INSTANCE", r, raising=False)
|
||||||
|
yield r
|
||||||
|
r.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeIface:
|
||||||
|
devPath = "/dev/cu.fake"
|
||||||
|
|
||||||
|
|
||||||
|
# -- parsers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseLogLine:
|
||||||
|
def test_full_prefix(self) -> None:
|
||||||
|
out = parse_log_line("INFO | 12:34:56 12345 [Main] Booting")
|
||||||
|
assert out["level"] == "INFO"
|
||||||
|
assert out["tag"] == "Main"
|
||||||
|
assert out["uptime_s"] == 12345
|
||||||
|
assert out["msg"] == "Booting"
|
||||||
|
assert out["clock"] == "12:34:56"
|
||||||
|
|
||||||
|
def test_invalid_clock(self) -> None:
|
||||||
|
out = parse_log_line("WARN | ??:??:?? 7 [SerialConsole] Boot")
|
||||||
|
assert out["level"] == "WARN"
|
||||||
|
assert out["clock"] == "??:??:??"
|
||||||
|
assert out["uptime_s"] == 7
|
||||||
|
|
||||||
|
def test_no_thread_bracket(self) -> None:
|
||||||
|
out = parse_log_line("DEBUG | 00:00:00 0 raw message body")
|
||||||
|
assert out["level"] == "DEBUG"
|
||||||
|
assert out.get("tag") is None
|
||||||
|
assert out["msg"] == "raw message body"
|
||||||
|
|
||||||
|
def test_bare_message(self) -> None:
|
||||||
|
# LogRecord.message path — no level prefix at all.
|
||||||
|
out = parse_log_line("just a bare message")
|
||||||
|
assert "level" not in out or out.get("level") is None
|
||||||
|
assert out["line"] == "just a bare message"
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
assert parse_log_line("") == {"line": ""}
|
||||||
|
|
||||||
|
def test_debug_heap_prefix_extracted(self) -> None:
|
||||||
|
out = parse_log_line("INFO | 12:34:56 12345 [Main] [heap 92344] Booting")
|
||||||
|
assert out["level"] == "INFO"
|
||||||
|
assert out["tag"] == "Main"
|
||||||
|
assert out["heap_free"] == 92344
|
||||||
|
assert out["msg"] == "Booting"
|
||||||
|
|
||||||
|
def test_debug_heap_prefix_on_bare_line(self) -> None:
|
||||||
|
# LogRecord.message path: no level prefix but still has [heap N].
|
||||||
|
out = parse_log_line("[heap 12345] some message")
|
||||||
|
assert out["heap_free"] == 12345
|
||||||
|
assert out["msg"] == "some message"
|
||||||
|
|
||||||
|
def test_thread_leak_event(self) -> None:
|
||||||
|
out = parse_log_line(
|
||||||
|
"HEAP | 00:00:01 100 [Power] [heap 90000] "
|
||||||
|
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------"
|
||||||
|
)
|
||||||
|
assert out["level"] == "HEAP"
|
||||||
|
assert out["heap_free"] == 90000
|
||||||
|
ev = out["heap_event"]
|
||||||
|
assert ev["kind"] == "leaked"
|
||||||
|
assert ev["thread"] == "MeshPacket"
|
||||||
|
assert ev["before"] == 92344
|
||||||
|
assert ev["after"] == 90000
|
||||||
|
assert ev["delta"] == -2344
|
||||||
|
|
||||||
|
def test_thread_freed_event(self) -> None:
|
||||||
|
out = parse_log_line(
|
||||||
|
"++++++ Thread Router freed heap 1000 -> 1500 (500) ++++++"
|
||||||
|
)
|
||||||
|
ev = out["heap_event"]
|
||||||
|
assert ev["kind"] == "freed"
|
||||||
|
assert ev["thread"] == "Router"
|
||||||
|
assert ev["delta"] == 500
|
||||||
|
|
||||||
|
def test_heap_status_periodic(self) -> None:
|
||||||
|
out = parse_log_line(
|
||||||
|
"HEAP | 00:00:30 30 [Power] "
|
||||||
|
"Heap status: 92344/200000 bytes free (-128), running 8/12 threads"
|
||||||
|
)
|
||||||
|
assert out["heap_free"] == 92344
|
||||||
|
assert out["heap_total"] == 200000
|
||||||
|
assert out["heap_delta"] == -128
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorderDebugHeapSynthesis:
|
||||||
|
def test_log_with_heap_writes_telemetry(self, recorder: "Recorder") -> None:
|
||||||
|
# When a log line carries [heap N], the recorder should also
|
||||||
|
# emit a synthesized telemetry row tagged source=debug_heap.
|
||||||
|
recorder._on_log_line(
|
||||||
|
"INFO | 00:00:00 1 [Main] [heap 88888] hello",
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
|
||||||
|
assert len(synth) == 1
|
||||||
|
assert synth[0]["fields"]["heap_free_bytes"] == 88888
|
||||||
|
assert synth[0]["variant"] == "local"
|
||||||
|
|
||||||
|
def test_heap_status_writes_total_too(self, recorder: "Recorder") -> None:
|
||||||
|
recorder._on_log_line(
|
||||||
|
"HEAP | 00:00:30 30 [Power] "
|
||||||
|
"Heap status: 50000/200000 bytes free (-100), running 8/12 threads",
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
|
||||||
|
assert synth[-1]["fields"]["heap_free_bytes"] == 50000
|
||||||
|
assert synth[-1]["fields"]["heap_total_bytes"] == 200000
|
||||||
|
|
||||||
|
def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None:
|
||||||
|
# Plain log line (no [heap N], no Heap status) — telemetry.jsonl
|
||||||
|
# should NOT gain a synth row.
|
||||||
|
before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface())
|
||||||
|
after = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||||
|
assert after == before
|
||||||
|
|
||||||
|
def test_thread_leak_event_persists_on_log_row(self, recorder: "Recorder") -> None:
|
||||||
|
recorder._on_log_line(
|
||||||
|
"HEAP | 00:00:01 100 [Power] [heap 90000] "
|
||||||
|
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------",
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
json.loads(r)
|
||||||
|
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
if r
|
||||||
|
]
|
||||||
|
evt_rows = [r for r in rows if r.get("heap_event")]
|
||||||
|
assert len(evt_rows) == 1
|
||||||
|
assert evt_rows[0]["heap_event"]["thread"] == "MeshPacket"
|
||||||
|
assert evt_rows[0]["heap_event"]["delta"] == -2344
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerialTap:
|
||||||
|
def test_serial_line_records_log_and_synthesizes_heap(
|
||||||
|
self, recorder: "Recorder"
|
||||||
|
) -> None:
|
||||||
|
recorder._on_serial_line(
|
||||||
|
"INFO | 00:00:00 5 [Main] [heap 88888] tap-line",
|
||||||
|
port="/dev/cu.tap",
|
||||||
|
)
|
||||||
|
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
log_rows = [json.loads(r) for r in logs if r]
|
||||||
|
# Find the row from this call (port=/dev/cu.tap, role=serial_session)
|
||||||
|
tap_rows = [r for r in log_rows if r.get("port") == "/dev/cu.tap"]
|
||||||
|
assert len(tap_rows) == 1
|
||||||
|
assert tap_rows[0]["role"] == "serial_session"
|
||||||
|
assert tap_rows[0]["level"] == "INFO"
|
||||||
|
assert tap_rows[0]["tag"] == "Main"
|
||||||
|
assert tap_rows[0]["heap_free"] == 88888
|
||||||
|
synth = [json.loads(r) for r in telem if '"source":"debug_heap_serial"' in r]
|
||||||
|
assert len(synth) == 1
|
||||||
|
assert synth[0]["fields"]["heap_free_bytes"] == 88888
|
||||||
|
assert synth[0]["role"] == "serial_session"
|
||||||
|
|
||||||
|
def test_serial_line_thread_leak_event(self, recorder: "Recorder") -> None:
|
||||||
|
recorder._on_serial_line(
|
||||||
|
"HEAP | 00:00:30 30 [Power] [heap 53484] "
|
||||||
|
"------ Thread Router leaked heap 53612 -> 53484 (-128) ------",
|
||||||
|
port="/dev/cu.tap",
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
json.loads(r)
|
||||||
|
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
if r
|
||||||
|
]
|
||||||
|
evt = [r for r in rows if r.get("heap_event")]
|
||||||
|
assert len(evt) == 1
|
||||||
|
assert evt[0]["heap_event"]["thread"] == "Router"
|
||||||
|
assert evt[0]["heap_event"]["delta"] == -128
|
||||||
|
# Heap also synthesized.
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text()
|
||||||
|
assert '"source":"debug_heap_serial"' in telem
|
||||||
|
|
||||||
|
def test_serial_line_pause(self, recorder: "Recorder") -> None:
|
||||||
|
recorder.pause("baseline")
|
||||||
|
recorder._on_serial_line(
|
||||||
|
"INFO | 00:00:00 1 [t] [heap 1000] dropped",
|
||||||
|
port="/dev/cu.tap",
|
||||||
|
)
|
||||||
|
# Only the pause event row should exist; no tap row.
|
||||||
|
logs = (recorder.base_dir / "logs.jsonl").read_text()
|
||||||
|
assert "dropped" not in logs
|
||||||
|
|
||||||
|
def test_serial_line_handler_swallows_exceptions(
|
||||||
|
self, recorder: "Recorder"
|
||||||
|
) -> None:
|
||||||
|
# Hostile input — should not raise.
|
||||||
|
recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type]
|
||||||
|
recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type]
|
||||||
|
# Survived.
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractTelemetry:
|
||||||
|
def test_local_stats_camel(self) -> None:
|
||||||
|
pkt = {
|
||||||
|
"decoded": {
|
||||||
|
"telemetry": {
|
||||||
|
"localStats": {"heap_total_bytes": 1000, "heap_free_bytes": 600}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = extract_telemetry(pkt)
|
||||||
|
assert out is not None
|
||||||
|
assert out["variant"] == "local"
|
||||||
|
assert out["fields"]["heap_free_bytes"] == 600
|
||||||
|
|
||||||
|
def test_device_metrics_snake(self) -> None:
|
||||||
|
pkt = {
|
||||||
|
"decoded": {
|
||||||
|
"telemetry": {"device_metrics": {"battery_level": 88, "voltage": 4.1}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = extract_telemetry(pkt)
|
||||||
|
assert out is not None
|
||||||
|
assert out["variant"] == "device"
|
||||||
|
assert out["fields"]["battery_level"] == 88
|
||||||
|
|
||||||
|
def test_unknown_variant_returns_none(self) -> None:
|
||||||
|
assert extract_telemetry({"decoded": {"telemetry": {"weird": {}}}}) is None
|
||||||
|
assert extract_telemetry({}) is None
|
||||||
|
assert extract_telemetry({"decoded": "not-a-dict"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarizePacket:
|
||||||
|
def test_text_with_payload(self) -> None:
|
||||||
|
pkt = {
|
||||||
|
"fromId": "!abc",
|
||||||
|
"toId": "!def",
|
||||||
|
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
|
||||||
|
"hopLimit": 3,
|
||||||
|
}
|
||||||
|
out = summarize_packet(pkt)
|
||||||
|
assert out["from_node"] == "!abc"
|
||||||
|
assert out["portnum"] == "TEXT_MESSAGE_APP"
|
||||||
|
assert out["payload_size"] == 5
|
||||||
|
assert out["payload_hex_prefix"] == "68656c6c6f"
|
||||||
|
|
||||||
|
def test_no_decoded(self) -> None:
|
||||||
|
out = summarize_packet({"fromId": "!abc"})
|
||||||
|
assert out["from_node"] == "!abc"
|
||||||
|
assert out["portnum"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestInterfaceLabel:
|
||||||
|
def test_serial(self) -> None:
|
||||||
|
assert interface_label(_FakeIface()) == {
|
||||||
|
"port": "/dev/cu.fake",
|
||||||
|
"role": "serial",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_tcp(self) -> None:
|
||||||
|
class T:
|
||||||
|
hostname = "node.lan"
|
||||||
|
portNumber = 4403
|
||||||
|
|
||||||
|
assert interface_label(T()) == {"port": "tcp://node.lan:4403", "role": "tcp"}
|
||||||
|
|
||||||
|
def test_unknown(self) -> None:
|
||||||
|
assert interface_label(object()) == {"port": "object", "role": None}
|
||||||
|
|
||||||
|
def test_none(self) -> None:
|
||||||
|
assert interface_label(None) == {"port": None, "role": None}
|
||||||
|
|
||||||
|
|
||||||
|
# -- recorder write side ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorderWrites:
|
||||||
|
def test_log_line_is_recorded(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface())
|
||||||
|
path = recorder.base_dir / "logs.jsonl"
|
||||||
|
rows = [json.loads(line) for line in path.read_text().splitlines() if line]
|
||||||
|
# First row is recorder_start_event mirror? No — that's events.jsonl only.
|
||||||
|
assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows)
|
||||||
|
|
||||||
|
def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None:
|
||||||
|
# _on_telemetry alone — only telemetry.jsonl
|
||||||
|
recorder._on_telemetry(
|
||||||
|
{
|
||||||
|
"fromId": "!abc",
|
||||||
|
"decoded": {"telemetry": {"localStats": {"heap_free_bytes": 600}}},
|
||||||
|
},
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
telem_rows = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
assert any('"variant":"local"' in r for r in telem_rows)
|
||||||
|
|
||||||
|
def test_packets_summary(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_receive(
|
||||||
|
{
|
||||||
|
"fromId": "!abc",
|
||||||
|
"toId": "!def",
|
||||||
|
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi"},
|
||||||
|
},
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
rows = (recorder.base_dir / "packets.jsonl").read_text().splitlines()
|
||||||
|
assert any('"portnum":"TEXT_MESSAGE_APP"' in r for r in rows)
|
||||||
|
|
||||||
|
def test_mark_event_round_trip(self, recorder: Recorder) -> None:
|
||||||
|
out = recorder.mark_event("checkpoint", note="midpoint")
|
||||||
|
assert "ts" in out
|
||||||
|
events = (recorder.base_dir / "events.jsonl").read_text().splitlines()
|
||||||
|
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
assert any('"label":"checkpoint"' in r and '"kind":"mark"' in r for r in events)
|
||||||
|
assert any('"level":"MARK"' in r and "checkpoint" in r for r in logs)
|
||||||
|
|
||||||
|
def test_pause_drops_writes(self, recorder: Recorder) -> None:
|
||||||
|
before = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
|
||||||
|
recorder.pause(reason="baseline")
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 1 [t] swallowed", _FakeIface())
|
||||||
|
after = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
|
||||||
|
assert after == before
|
||||||
|
recorder.resume()
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 2 [t] kept", _FakeIface())
|
||||||
|
post_resume = (recorder.base_dir / "logs.jsonl").read_text()
|
||||||
|
assert "kept" in post_resume
|
||||||
|
|
||||||
|
def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None:
|
||||||
|
# If the writer dies, the pubsub callback must NOT raise — that
|
||||||
|
# would crash the meshtastic receive thread.
|
||||||
|
bad_packet = object() # not a dict
|
||||||
|
recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||||
|
recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||||
|
recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type]
|
||||||
|
# No assertion needed — survival is the test.
|
||||||
|
|
||||||
|
|
||||||
|
# -- log_query read side ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogQuery:
|
||||||
|
def test_logs_window_grep_and_level(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
|
||||||
|
recorder._on_log_line("WARN | 12:00:01 2 [B] bravo failed", _FakeIface())
|
||||||
|
recorder._on_log_line("ERROR | 12:00:02 3 [C] charlie failed", _FakeIface())
|
||||||
|
|
||||||
|
out = log_query.logs_window(start="-1m", level="WARN|ERROR", max_lines=10)
|
||||||
|
assert out["total_matched"] == 2
|
||||||
|
levels = {r["level"] for r in out["lines"]}
|
||||||
|
assert levels == {"WARN", "ERROR"}
|
||||||
|
|
||||||
|
out2 = log_query.logs_window(start="-1m", grep=r"failed$", max_lines=10)
|
||||||
|
assert out2["total_matched"] == 2
|
||||||
|
|
||||||
|
def test_logs_window_invalid_regex(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
|
||||||
|
with pytest.raises(ValueError, match="invalid grep regex"):
|
||||||
|
log_query.logs_window(start="-1m", grep="(")
|
||||||
|
|
||||||
|
def test_telemetry_timeline_slope_and_downsample(self, recorder: Recorder) -> None:
|
||||||
|
# Synthesize a downward leak: 100 points, free_heap drops 1 byte/sample.
|
||||||
|
base_ts = time.time() - 60
|
||||||
|
for i in range(100):
|
||||||
|
recorder._files["telemetry"].write(
|
||||||
|
{
|
||||||
|
"ts": base_ts + i * 0.5,
|
||||||
|
"port": "/dev/cu.fake",
|
||||||
|
"role": "serial",
|
||||||
|
"from_node": "!abc",
|
||||||
|
"variant": "local",
|
||||||
|
"fields": {"heap_free_bytes": 10000 - i},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
out = log_query.telemetry_timeline(
|
||||||
|
window="2m", variant="local", field="free_heap", max_points=10
|
||||||
|
)
|
||||||
|
assert out["samples"] == 100
|
||||||
|
assert len(out["points"]) <= 10
|
||||||
|
# Negative slope (heap dropping). Magnitude: 1 byte every 0.5s = 120/min.
|
||||||
|
assert out["slope_per_min"] is not None
|
||||||
|
assert out["slope_per_min"] < -100
|
||||||
|
|
||||||
|
def test_export_bundles_slice(self, recorder: Recorder, tmp_path: Path) -> None:
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 1 [t] one", _FakeIface())
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 2 [t] two", _FakeIface())
|
||||||
|
dest = tmp_path / "bundle"
|
||||||
|
out = log_query.export(start="-1m", end="now", dest_dir=str(dest))
|
||||||
|
assert (dest / "logs.jsonl").exists()
|
||||||
|
assert "logs" in out["paths"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- time parser -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTime:
|
||||||
|
def test_relative(self) -> None:
|
||||||
|
now = 1_000_000.0
|
||||||
|
assert log_query._parse_time("-15m", now=now) == now - 900
|
||||||
|
assert log_query._parse_time("-2h", now=now) == now - 7200
|
||||||
|
assert log_query._parse_time("-1d", now=now) == now - 86400
|
||||||
|
|
||||||
|
def test_now_and_epoch(self) -> None:
|
||||||
|
now = 1_000_000.0
|
||||||
|
assert log_query._parse_time("now", now=now) == now
|
||||||
|
assert log_query._parse_time(now) == now
|
||||||
|
|
||||||
|
def test_iso(self) -> None:
|
||||||
|
ts = log_query._parse_time("2026-01-01T00:00:00Z")
|
||||||
|
assert isinstance(ts, float) and ts > 1_700_000_000
|
||||||
|
|
||||||
|
def test_naive_iso_assumes_utc(self) -> None:
|
||||||
|
assert log_query._parse_time("2026-01-01T00:00:00") == log_query._parse_time(
|
||||||
|
"2026-01-01T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid(self) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
log_query._parse_time("not a time")
|
||||||
|
|
||||||
|
|
||||||
|
# -- rotation --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRotation:
|
||||||
|
def test_size_cap_rotates_and_gzips(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, max_bytes=512, keep_archives=5, check_every=1)
|
||||||
|
for i in range(100):
|
||||||
|
r.write({"ts": float(i), "i": i, "pad": "x" * 40})
|
||||||
|
r.close()
|
||||||
|
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert archives, "expected at least one rotation"
|
||||||
|
# Archive content is valid gzip + valid JSONL
|
||||||
|
with gzip.open(archives[0], "rt") as fh:
|
||||||
|
first = json.loads(fh.readline())
|
||||||
|
assert "ts" in first
|
||||||
|
|
||||||
|
def test_archive_pruning(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, max_bytes=200, keep_archives=2, check_every=1)
|
||||||
|
# Force several rotations.
|
||||||
|
for _ in range(8):
|
||||||
|
for i in range(20):
|
||||||
|
r.write({"ts": float(i), "pad": "x" * 30})
|
||||||
|
r.force_rotate()
|
||||||
|
r.close()
|
||||||
|
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert len(archives) <= 2, f"expected ≤2 kept archives, got {len(archives)}"
|
||||||
|
|
||||||
|
def test_archive_pruning_uses_filename_order(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, keep_archives=2)
|
||||||
|
old = tmp_path / "rot.20260101-000000-000000-00000.jsonl.gz"
|
||||||
|
mid = tmp_path / "rot.20260101-000001-000000-00000.jsonl.gz"
|
||||||
|
new = tmp_path / "rot.20260101-000002-000000-00000.jsonl.gz"
|
||||||
|
for archive in (old, mid, new):
|
||||||
|
with gzip.open(archive, "wt", encoding="utf-8") as fh:
|
||||||
|
fh.write('{"ts":1}\n')
|
||||||
|
# Deliberately scramble mtimes so lexicographic filename order is
|
||||||
|
# the only stable chronological signal.
|
||||||
|
os.utime(old, (300, 300))
|
||||||
|
os.utime(mid, (100, 100))
|
||||||
|
os.utime(new, (200, 200))
|
||||||
|
|
||||||
|
r._prune_archives()
|
||||||
|
r.close()
|
||||||
|
|
||||||
|
archives = sorted(p.name for p in tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert archives == [mid.name, new.name]
|
||||||
|
|
||||||
|
def test_force_rotate_when_below_threshold(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, max_bytes=10_000_000, check_every=999_999)
|
||||||
|
r.write({"ts": 1.0, "msg": "tiny"})
|
||||||
|
r.force_rotate()
|
||||||
|
r.write({"ts": 2.0, "msg": "after-rotate"})
|
||||||
|
r.close()
|
||||||
|
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert len(archives) == 1
|
||||||
|
assert path.exists()
|
||||||
|
assert "after-rotate" in path.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorderLocks:
|
||||||
|
def test_force_rotate_all_returns_status(self, recorder: Recorder) -> None:
|
||||||
|
out = recorder.force_rotate_all()
|
||||||
|
assert out["running"] is True
|
||||||
|
assert out["files"]
|
||||||
|
|
||||||
|
def test_wire_pubsub_logs_subscription_failure(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
class FailingPubSubMock:
|
||||||
|
def subscribe(self, callback: object, topic: str) -> None:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pubsub, "pub", FailingPubSubMock())
|
||||||
|
recorder = Recorder(base_dir=tmp_path)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
recorder._wire_pubsub()
|
||||||
|
assert (
|
||||||
|
"Recorder failed to subscribe to meshtastic.log.line: boom" in caplog.text
|
||||||
|
)
|
||||||
+1
-2
@@ -49,6 +49,7 @@ build_flags = -Wno-missing-field-initializers
|
|||||||
-DRADIOLIB_EXCLUDE_PAGER=1
|
-DRADIOLIB_EXCLUDE_PAGER=1
|
||||||
-DRADIOLIB_EXCLUDE_FSK4=1
|
-DRADIOLIB_EXCLUDE_FSK4=1
|
||||||
-DRADIOLIB_EXCLUDE_APRS=1
|
-DRADIOLIB_EXCLUDE_APRS=1
|
||||||
|
-DRADIOLIB_EXCLUDE_ADSB=1
|
||||||
-DRADIOLIB_EXCLUDE_LORAWAN=1
|
-DRADIOLIB_EXCLUDE_LORAWAN=1
|
||||||
-DMESHTASTIC_EXCLUDE_DROPZONE=1
|
-DMESHTASTIC_EXCLUDE_DROPZONE=1
|
||||||
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
|
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
|
||||||
@@ -138,8 +139,6 @@ lib_deps =
|
|||||||
https://github.com/adafruit/Adafruit-GFX-Library/archive/refs/tags/1.12.6.zip
|
https://github.com/adafruit/Adafruit-GFX-Library/archive/refs/tags/1.12.6.zip
|
||||||
# renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel
|
# renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel
|
||||||
https://github.com/adafruit/Adafruit_NeoPixel/archive/refs/tags/1.15.4.zip
|
https://github.com/adafruit/Adafruit_NeoPixel/archive/refs/tags/1.15.4.zip
|
||||||
# renovate: datasource=github-tags depName=Adafruit SSD1306 packageName=adafruit/Adafruit_SSD1306
|
|
||||||
https://github.com/adafruit/Adafruit_SSD1306/archive/refs/tags/2.5.16.zip
|
|
||||||
# renovate: datasource=github-tags depName=Adafruit BMP280 packageName=adafruit/Adafruit_BMP280_Library
|
# renovate: datasource=github-tags depName=Adafruit BMP280 packageName=adafruit/Adafruit_BMP280_Library
|
||||||
https://github.com/adafruit/Adafruit_BMP280_Library/archive/refs/tags/3.0.0.zip
|
https://github.com/adafruit/Adafruit_BMP280_Library/archive/refs/tags/3.0.0.zip
|
||||||
# renovate: datasource=github-tags depName=Adafruit BMP085 packageName=adafruit/Adafruit-BMP085-Library
|
# renovate: datasource=github-tags depName=Adafruit BMP085 packageName=adafruit/Adafruit-BMP085-Library
|
||||||
|
|||||||
+1
-1
Submodule protobufs updated: 1d6f1a71ff...ff5b392503
@@ -151,7 +151,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess
|
|||||||
if (!this->_enabled)
|
if (!this->_enabled)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if ((this->_server == NULL && this->_ip == INADDR_NONE) || this->_port == 0)
|
if ((this->_server == NULL && this->_ip == IPAddress(0, 0, 0, 0)) || this->_port == 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Check priority against priMask values.
|
// Check priority against priMask values.
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ extern MemGet memGet;
|
|||||||
#define LED_STATE_ON 1
|
#define LED_STATE_ON 1
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// WIFI LED
|
||||||
|
#ifndef WIFI_STATE_ON
|
||||||
|
#define WIFI_STATE_ON 1
|
||||||
|
#endif
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// DEBUG
|
// DEBUG
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -147,7 +152,9 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
|
|||||||
// Default Bluetooth PIN
|
// Default Bluetooth PIN
|
||||||
#define defaultBLEPin 123456
|
#define defaultBLEPin 123456
|
||||||
|
|
||||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||||
|
#include <ESP32_CH390.h>
|
||||||
|
#elif HAS_ETHERNET && !defined(USE_WS5500)
|
||||||
#include <RAK13800_W5100S.h>
|
#include <RAK13800_W5100S.h>
|
||||||
#endif // HAS_ETHERNET
|
#endif // HAS_ETHERNET
|
||||||
|
|
||||||
|
|||||||
+3
-6
@@ -53,8 +53,7 @@ class GPSStatus : public Status
|
|||||||
int32_t getLatitude() const
|
int32_t getLatitude() const
|
||||||
{
|
{
|
||||||
if (config.position.fixed_position) {
|
if (config.position.fixed_position) {
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
return localPosition.latitude_i;
|
||||||
return node->position.latitude_i;
|
|
||||||
} else {
|
} else {
|
||||||
return p.latitude_i;
|
return p.latitude_i;
|
||||||
}
|
}
|
||||||
@@ -63,8 +62,7 @@ class GPSStatus : public Status
|
|||||||
int32_t getLongitude() const
|
int32_t getLongitude() const
|
||||||
{
|
{
|
||||||
if (config.position.fixed_position) {
|
if (config.position.fixed_position) {
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
return localPosition.longitude_i;
|
||||||
return node->position.longitude_i;
|
|
||||||
} else {
|
} else {
|
||||||
return p.longitude_i;
|
return p.longitude_i;
|
||||||
}
|
}
|
||||||
@@ -73,8 +71,7 @@ class GPSStatus : public Status
|
|||||||
int32_t getAltitude() const
|
int32_t getAltitude() const
|
||||||
{
|
{
|
||||||
if (config.position.fixed_position) {
|
if (config.position.fixed_position) {
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
return localPosition.altitude;
|
||||||
return node->position.altitude;
|
|
||||||
} else {
|
} else {
|
||||||
return p.altitude;
|
return p.altitude;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-31
@@ -6,7 +6,6 @@
|
|||||||
#include "SPILock.h"
|
#include "SPILock.h"
|
||||||
#include "SafeFile.h"
|
#include "SafeFile.h"
|
||||||
#include "gps/RTC.h"
|
#include "gps/RTC.h"
|
||||||
#include "graphics/draw/MessageRenderer.h"
|
|
||||||
#include <cstring> // memcpy
|
#include <cstring> // memcpy
|
||||||
|
|
||||||
#ifndef MESSAGE_TEXT_POOL_SIZE
|
#ifndef MESSAGE_TEXT_POOL_SIZE
|
||||||
@@ -181,13 +180,8 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
|
|||||||
|
|
||||||
bool isDM = (sm.dest != 0 && sm.dest != NODENUM_BROADCAST);
|
bool isDM = (sm.dest != 0 && sm.dest != NODENUM_BROADCAST);
|
||||||
|
|
||||||
if (packet.from == 0) {
|
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
||||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
sm.ackStatus = (packet.from == 0) ? AckStatus::NONE : AckStatus::ACKED;
|
||||||
sm.ackStatus = AckStatus::NONE;
|
|
||||||
} else {
|
|
||||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
|
||||||
sm.ackStatus = AckStatus::ACKED;
|
|
||||||
}
|
|
||||||
|
|
||||||
addLiveMessage(sm);
|
addLiveMessage(sm);
|
||||||
|
|
||||||
@@ -372,26 +366,25 @@ void MessageStore::clearAllMessages()
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper: erase first or last message matching a predicate
|
// Internal helpers for targeted erasure.
|
||||||
template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deque, Predicate pred, bool fromBack = false)
|
template <typename Predicate> static bool eraseFirstMatch(std::deque<StoredMessage> &deque, Predicate pred)
|
||||||
{
|
{
|
||||||
if (fromBack) {
|
for (auto it = deque.begin(); it != deque.end(); ++it) {
|
||||||
// Iterate from the back and erase all matches from the end
|
if (pred(*it)) {
|
||||||
for (auto it = deque.rbegin(); it != deque.rend();) {
|
deque.erase(it);
|
||||||
if (pred(*it)) {
|
return true;
|
||||||
it = std::deque<StoredMessage>::reverse_iterator(deque.erase(std::next(it).base()));
|
|
||||||
} else {
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Manual forward search to erase all matches
|
return false;
|
||||||
for (auto it = deque.begin(); it != deque.end();) {
|
}
|
||||||
if (pred(*it)) {
|
|
||||||
it = deque.erase(it);
|
template <typename Predicate> static void eraseAllMatches(std::deque<StoredMessage> &deque, Predicate pred)
|
||||||
} else {
|
{
|
||||||
++it;
|
for (auto it = deque.begin(); it != deque.end();) {
|
||||||
}
|
if (pred(*it)) {
|
||||||
|
it = deque.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,7 +392,9 @@ template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deq
|
|||||||
// Delete oldest message (RAM + persisted queue)
|
// Delete oldest message (RAM + persisted queue)
|
||||||
void MessageStore::deleteOldestMessage()
|
void MessageStore::deleteOldestMessage()
|
||||||
{
|
{
|
||||||
eraseIf(liveMessages, [](StoredMessage &) { return true; });
|
if (!liveMessages.empty()) {
|
||||||
|
liveMessages.pop_front();
|
||||||
|
}
|
||||||
saveToFlash();
|
saveToFlash();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,14 +402,14 @@ void MessageStore::deleteOldestMessage()
|
|||||||
void MessageStore::deleteOldestMessageInChannel(uint8_t channel)
|
void MessageStore::deleteOldestMessageInChannel(uint8_t channel)
|
||||||
{
|
{
|
||||||
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
||||||
eraseIf(liveMessages, pred);
|
eraseFirstMatch(liveMessages, pred);
|
||||||
saveToFlash();
|
saveToFlash();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MessageStore::deleteAllMessagesInChannel(uint8_t channel)
|
void MessageStore::deleteAllMessagesInChannel(uint8_t channel)
|
||||||
{
|
{
|
||||||
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
||||||
eraseIf(liveMessages, pred, false /* delete ALL, not just first */);
|
eraseAllMatches(liveMessages, pred);
|
||||||
saveToFlash();
|
saveToFlash();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +422,7 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer)
|
|||||||
uint32_t other = (m.sender == local) ? m.dest : m.sender;
|
uint32_t other = (m.sender == local) ? m.dest : m.sender;
|
||||||
return other == peer;
|
return other == peer;
|
||||||
};
|
};
|
||||||
eraseIf(liveMessages, pred, false);
|
eraseAllMatches(liveMessages, pred);
|
||||||
saveToFlash();
|
saveToFlash();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +435,7 @@ void MessageStore::deleteOldestMessageWithPeer(uint32_t peer)
|
|||||||
uint32_t other = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
|
uint32_t other = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
|
||||||
return other == peer;
|
return other == peer;
|
||||||
};
|
};
|
||||||
eraseIf(liveMessages, pred);
|
eraseFirstMatch(liveMessages, pred);
|
||||||
saveToFlash();
|
saveToFlash();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,6 @@ class MessageStore
|
|||||||
// Allocate text into pool (used by sender-side code)
|
// Allocate text into pool (used by sender-side code)
|
||||||
static uint16_t storeText(const char *src, size_t len);
|
static uint16_t storeText(const char *src, size_t len);
|
||||||
|
|
||||||
// Used when loading from flash to rebuild the text pool
|
|
||||||
static uint16_t rebuildTextFromFlash(const char *src, size_t len);
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
|
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
|
||||||
std::string filename; // Flash filename for persistence
|
std::string filename; // Flash filename for persistence
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_
|
|||||||
auto thread = concurrency::OSThread::currentThread;
|
auto thread = concurrency::OSThread::currentThread;
|
||||||
meshtastic_LogRecord logRecord = meshtastic_LogRecord_init_zero;
|
meshtastic_LogRecord logRecord = meshtastic_LogRecord_init_zero;
|
||||||
logRecord.level = getLogLevel(logLevel);
|
logRecord.level = getLogLevel(logLevel);
|
||||||
vsprintf(logRecord.message, format, arg);
|
vsnprintf(logRecord.message, sizeof(logRecord.message), format, arg);
|
||||||
if (thread)
|
if (thread)
|
||||||
strcpy(logRecord.source, thread->ThreadName.c_str());
|
strlcpy(logRecord.source, thread->ThreadName.c_str(), sizeof(logRecord.source));
|
||||||
logRecord.time = getValidTime(RTCQuality::RTCQualityDevice, true);
|
logRecord.time = getValidTime(RTCQuality::RTCQualityDevice, true);
|
||||||
|
|
||||||
auto buffer = std::unique_ptr<uint8_t[]>(new uint8_t[meshtastic_LogRecord_size]);
|
auto buffer = std::unique_ptr<uint8_t[]>(new uint8_t[meshtastic_LogRecord_size]);
|
||||||
|
|||||||
+1
-134
@@ -65,7 +65,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#include "mesh/Default.h"
|
#include "mesh/Default.h"
|
||||||
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
||||||
#include "modules/ExternalNotificationModule.h"
|
#include "modules/ExternalNotificationModule.h"
|
||||||
#include "modules/TextMessageModule.h"
|
|
||||||
#include "modules/WaypointModule.h"
|
#include "modules/WaypointModule.h"
|
||||||
#include "sleep.h"
|
#include "sleep.h"
|
||||||
#include "target_specific.h"
|
#include "target_specific.h"
|
||||||
@@ -1285,7 +1284,7 @@ void Screen::setFrames(FrameFocus focus)
|
|||||||
|
|
||||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||||
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||||||
if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
|
if (n && n->num != nodeDB->getNodeNum() && nodeInfoLiteIsFavorite(n)) {
|
||||||
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1643,138 +1642,6 @@ int Screen::handleStatusUpdate(const meshtastic::Status *arg)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles when message is received; will jump to text message frame.
|
|
||||||
int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
|
|
||||||
{
|
|
||||||
if (showingNormalScreen) {
|
|
||||||
if (packet->from == 0) {
|
|
||||||
// Outgoing message (likely sent from phone)
|
|
||||||
devicestate.has_rx_text_message = false;
|
|
||||||
memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message));
|
|
||||||
hiddenFrames.textMessage = true;
|
|
||||||
hasUnreadMessage = false; // Clear unread state when user replies
|
|
||||||
|
|
||||||
setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list
|
|
||||||
} else {
|
|
||||||
// Incoming message
|
|
||||||
devicestate.has_rx_text_message = true; // Needed to include the message frame
|
|
||||||
hasUnreadMessage = true; // Enables mail icon in the header
|
|
||||||
setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view (no-op during text_input)
|
|
||||||
|
|
||||||
// Only wake/force display if the configuration allows it
|
|
||||||
if (shouldWakeOnReceivedMessage()) {
|
|
||||||
setOn(true); // Wake up the screen first
|
|
||||||
forceDisplay(); // Forces screen redraw
|
|
||||||
}
|
|
||||||
// === Prepare banner/popup content ===
|
|
||||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from);
|
|
||||||
const meshtastic_Channel channel =
|
|
||||||
channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex());
|
|
||||||
const char *longName = (node && node->has_user) ? node->user.long_name : nullptr;
|
|
||||||
|
|
||||||
const char *msgRaw = reinterpret_cast<const char *>(packet->decoded.payload.bytes);
|
|
||||||
|
|
||||||
char banner[256];
|
|
||||||
|
|
||||||
bool isAlert = false;
|
|
||||||
|
|
||||||
if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra ||
|
|
||||||
moduleConfig.external_notification.alert_bell_buzzer)
|
|
||||||
// Check for bell character to determine if this message is an alert
|
|
||||||
for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) {
|
|
||||||
if (msgRaw[i] == ASCII_BELL) {
|
|
||||||
isAlert = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlike generic messages, alerts (when enabled via the ext notif module) ignore any
|
|
||||||
// 'mute' preferences set to any specific node or channel.
|
|
||||||
// If on-screen keyboard is active, show a transient popup over keyboard instead of interrupting it
|
|
||||||
if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) {
|
|
||||||
// Wake and force redraw so popup is visible immediately
|
|
||||||
if (shouldWakeOnReceivedMessage()) {
|
|
||||||
setOn(true);
|
|
||||||
forceDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build popup: title = message source name, content = message text (sanitized)
|
|
||||||
// Title
|
|
||||||
char titleBuf[64] = {0};
|
|
||||||
if (longName && longName[0]) {
|
|
||||||
// Sanitize sender name
|
|
||||||
std::string t = sanitizeString(longName);
|
|
||||||
strncpy(titleBuf, t.c_str(), sizeof(titleBuf) - 1);
|
|
||||||
} else {
|
|
||||||
strncpy(titleBuf, "Message", sizeof(titleBuf) - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content: payload bytes may not be null-terminated, remove ASCII_BELL and sanitize
|
|
||||||
char content[256] = {0};
|
|
||||||
{
|
|
||||||
std::string raw;
|
|
||||||
raw.reserve(packet->decoded.payload.size);
|
|
||||||
for (size_t i = 0; i < packet->decoded.payload.size; ++i) {
|
|
||||||
char c = msgRaw[i];
|
|
||||||
if (c == ASCII_BELL)
|
|
||||||
continue; // strip bell
|
|
||||||
raw.push_back(c);
|
|
||||||
}
|
|
||||||
std::string sanitized = sanitizeString(raw);
|
|
||||||
strncpy(content, sanitized.c_str(), sizeof(content) - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
NotificationRenderer::showKeyboardMessagePopupWithTitle(titleBuf, content, 3000);
|
|
||||||
|
|
||||||
// Maintain existing buzzer behavior on M5 if applicable
|
|
||||||
#if defined(M5STACK_UNITC6L)
|
|
||||||
if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY ||
|
|
||||||
(isAlert && moduleConfig.external_notification.alert_bell_buzzer) ||
|
|
||||||
(!isBroadcast(packet->to) && isToUs(packet))) {
|
|
||||||
playLongBeep();
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
} else {
|
|
||||||
// No keyboard active: use regular banner flow, respecting mute settings
|
|
||||||
if (isAlert) {
|
|
||||||
if (longName && longName[0]) {
|
|
||||||
snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName);
|
|
||||||
} else {
|
|
||||||
strcpy(banner, "Alert Received");
|
|
||||||
}
|
|
||||||
screen->showSimpleBanner(banner, 3000);
|
|
||||||
} else if (!channel.settings.has_module_settings || !channel.settings.module_settings.is_muted) {
|
|
||||||
if (longName && longName[0]) {
|
|
||||||
if (currentResolution == ScreenResolution::UltraLow) {
|
|
||||||
strcpy(banner, "New Message");
|
|
||||||
} else {
|
|
||||||
snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
strcpy(banner, "New Message");
|
|
||||||
}
|
|
||||||
#if defined(M5STACK_UNITC6L)
|
|
||||||
screen->setOn(true);
|
|
||||||
screen->showSimpleBanner(banner, 1500);
|
|
||||||
if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY ||
|
|
||||||
(isAlert && moduleConfig.external_notification.alert_bell_buzzer) ||
|
|
||||||
(!isBroadcast(packet->to) && isToUs(packet))) {
|
|
||||||
// Beep if not in DIRECT_MSG_ONLY mode or if in DIRECT_MSG_ONLY mode and either
|
|
||||||
// - packet contains an alert and alert bell buzzer is enabled
|
|
||||||
// - packet is a non-broadcast that is addressed to this node
|
|
||||||
playLongBeep();
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
screen->showSimpleBanner(banner, 3000);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Triggered by MeshModules
|
// Triggered by MeshModules
|
||||||
int Screen::handleUIFrameEvent(const UIFrameEvent *event)
|
int Screen::handleUIFrameEvent(const UIFrameEvent *event)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -609,7 +609,6 @@ class Screen : public concurrency::OSThread
|
|||||||
|
|
||||||
// Handle observer events
|
// Handle observer events
|
||||||
int handleStatusUpdate(const meshtastic::Status *arg);
|
int handleStatusUpdate(const meshtastic::Status *arg);
|
||||||
int handleTextMessage(const meshtastic_MeshPacket *packet);
|
|
||||||
int handleUIFrameEvent(const UIFrameEvent *arg);
|
int handleUIFrameEvent(const UIFrameEvent *arg);
|
||||||
int handleInputEvent(const InputEvent *arg);
|
int handleInputEvent(const InputEvent *arg);
|
||||||
int handleAdminMessage(AdminModule_ObserverData *arg);
|
int handleAdminMessage(AdminModule_ObserverData *arg);
|
||||||
|
|||||||
@@ -57,6 +57,70 @@ BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOp
|
|||||||
return bannerOptions;
|
return bannerOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StoredMessage *getNewestMessageForActiveThread()
|
||||||
|
{
|
||||||
|
const auto &messages = messageStore.getMessages();
|
||||||
|
if (messages.empty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto mode = graphics::MessageRenderer::getThreadMode();
|
||||||
|
const int channel = graphics::MessageRenderer::getThreadChannel();
|
||||||
|
const uint32_t peer = graphics::MessageRenderer::getThreadPeer();
|
||||||
|
const uint32_t localNode = nodeDB->getNodeNum();
|
||||||
|
|
||||||
|
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
|
||||||
|
return &messages.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
|
||||||
|
const StoredMessage &m = *it;
|
||||||
|
|
||||||
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
||||||
|
if (m.type == MessageType::BROADCAST && static_cast<int>(m.channelIndex) == channel) {
|
||||||
|
return &m;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
||||||
|
if (m.type != MessageType::DM_TO_US) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint32_t other = (m.sender == localNode) ? m.dest : m.sender;
|
||||||
|
if (other == peer) {
|
||||||
|
return &m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void launchReplyForMessage(const StoredMessage &message, bool freetext)
|
||||||
|
{
|
||||||
|
if (message.type == MessageType::BROADCAST || message.dest == NODENUM_BROADCAST) {
|
||||||
|
if (freetext) {
|
||||||
|
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, message.channelIndex);
|
||||||
|
} else {
|
||||||
|
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, message.channelIndex);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t localNode = nodeDB->getNodeNum();
|
||||||
|
const uint32_t peer = (message.sender == localNode) ? message.dest : message.sender;
|
||||||
|
if (peer == 0 || peer == NODENUM_BROADCAST) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freetext) {
|
||||||
|
cannedMessageModule->LaunchFreetextWithDestination(peer);
|
||||||
|
} else {
|
||||||
|
cannedMessageModule->LaunchWithDestination(peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
|
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
|
||||||
@@ -594,9 +658,12 @@ void menuHandler::messageResponseMenu()
|
|||||||
|
|
||||||
#ifdef HAS_I2S
|
#ifdef HAS_I2S
|
||||||
} else if (selected == Aloud) {
|
} else if (selected == Aloud) {
|
||||||
const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
|
if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
||||||
const char *msg = reinterpret_cast<const char *>(mp.decoded.payload.bytes);
|
const char *msg = MessageStore::getText(*latest);
|
||||||
audioThread->readAloud(msg);
|
if (msg && msg[0]) {
|
||||||
|
audioThread->readAloud(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -656,20 +723,12 @@ void menuHandler::replyMenu()
|
|||||||
|
|
||||||
// Preset reply
|
// Preset reply
|
||||||
if (selected == ReplyPreset) {
|
if (selected == ReplyPreset) {
|
||||||
|
|
||||||
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
||||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, ch);
|
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, ch);
|
||||||
|
|
||||||
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
||||||
cannedMessageModule->LaunchWithDestination(peer);
|
cannedMessageModule->LaunchWithDestination(peer);
|
||||||
|
} else if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
||||||
} else {
|
launchReplyForMessage(*latest, false);
|
||||||
// Fallback for last received message
|
|
||||||
if (devicestate.rx_text_message.to == NODENUM_BROADCAST) {
|
|
||||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel);
|
|
||||||
} else {
|
|
||||||
cannedMessageModule->LaunchWithDestination(devicestate.rx_text_message.from);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -677,20 +736,12 @@ void menuHandler::replyMenu()
|
|||||||
|
|
||||||
// Freetext reply
|
// Freetext reply
|
||||||
if (selected == ReplyFreetext) {
|
if (selected == ReplyFreetext) {
|
||||||
|
|
||||||
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
||||||
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, ch);
|
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, ch);
|
||||||
|
|
||||||
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
||||||
cannedMessageModule->LaunchFreetextWithDestination(peer);
|
cannedMessageModule->LaunchFreetextWithDestination(peer);
|
||||||
|
} else if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
||||||
} else {
|
launchReplyForMessage(*latest, true);
|
||||||
// Fallback for last received message
|
|
||||||
if (devicestate.rx_text_message.to == NODENUM_BROADCAST) {
|
|
||||||
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel);
|
|
||||||
} else {
|
|
||||||
cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -845,10 +896,10 @@ void menuHandler::messageViewModeMenu()
|
|||||||
// Encode peers
|
// Encode peers
|
||||||
for (size_t i = 0; i < uniquePeers.size(); ++i) {
|
for (size_t i = 0; i < uniquePeers.size(); ++i) {
|
||||||
uint32_t peer = uniquePeers[i];
|
uint32_t peer = uniquePeers[i];
|
||||||
auto node = nodeDB->getMeshNode(peer);
|
const auto *node = nodeDB->getMeshNode(peer);
|
||||||
std::string name;
|
std::string name;
|
||||||
if (node && node->has_user)
|
if (nodeInfoLiteHasUser(node))
|
||||||
name = sanitizeString(node->user.long_name).substr(0, 15);
|
name = sanitizeString(node->long_name).substr(0, 15);
|
||||||
else {
|
else {
|
||||||
char buf[20];
|
char buf[20];
|
||||||
snprintf(buf, sizeof(buf), "Node %08X", peer);
|
snprintf(buf, sizeof(buf), "Node %08X", peer);
|
||||||
@@ -1355,14 +1406,14 @@ void menuHandler::manageNodeMenu()
|
|||||||
static int optionsEnumArray[enumEnd] = {Back};
|
static int optionsEnumArray[enumEnd] = {Back};
|
||||||
int options = 1;
|
int options = 1;
|
||||||
|
|
||||||
if (node->is_favorite) {
|
if (nodeInfoLiteIsFavorite(node)) {
|
||||||
optionsArray[options] = "Unfavorite";
|
optionsArray[options] = "Unfavorite";
|
||||||
} else {
|
} else {
|
||||||
optionsArray[options] = "Favorite";
|
optionsArray[options] = "Favorite";
|
||||||
}
|
}
|
||||||
optionsEnumArray[options++] = Favorite;
|
optionsEnumArray[options++] = Favorite;
|
||||||
|
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
optionsArray[options] = "Unmute Notifications";
|
optionsArray[options] = "Unmute Notifications";
|
||||||
} else {
|
} else {
|
||||||
@@ -1376,7 +1427,7 @@ void menuHandler::manageNodeMenu()
|
|||||||
optionsArray[options] = "Key Verification";
|
optionsArray[options] = "Key Verification";
|
||||||
optionsEnumArray[options++] = KeyVerification;
|
optionsEnumArray[options++] = KeyVerification;
|
||||||
|
|
||||||
if (node->is_ignored) {
|
if (nodeInfoLiteIsIgnored(node)) {
|
||||||
optionsArray[options] = "Unignore Node";
|
optionsArray[options] = "Unignore Node";
|
||||||
} else {
|
} else {
|
||||||
optionsArray[options] = "Ignore Node";
|
optionsArray[options] = "Ignore Node";
|
||||||
@@ -1386,8 +1437,8 @@ void menuHandler::manageNodeMenu()
|
|||||||
BannerOverlayOptions bannerOptions;
|
BannerOverlayOptions bannerOptions;
|
||||||
|
|
||||||
std::string title = "";
|
std::string title = "";
|
||||||
if (node->has_user && node->user.long_name && node->user.long_name[0]) {
|
if (nodeInfoLiteHasUser(node) && node->long_name[0]) {
|
||||||
title += sanitizeString(node->user.long_name).substr(0, 15);
|
title += sanitizeString(node->long_name).substr(0, 15);
|
||||||
} else {
|
} else {
|
||||||
char buf[20];
|
char buf[20];
|
||||||
snprintf(buf, sizeof(buf), "%08X", (unsigned int)node->num);
|
snprintf(buf, sizeof(buf), "%08X", (unsigned int)node->num);
|
||||||
@@ -1409,7 +1460,7 @@ void menuHandler::manageNodeMenu()
|
|||||||
if (!n) {
|
if (!n) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (n->is_favorite) {
|
if (nodeInfoLiteIsFavorite(n)) {
|
||||||
LOG_INFO("Removing node %08X from favorites", menuHandler::pickedNodeNum);
|
LOG_INFO("Removing node %08X from favorites", menuHandler::pickedNodeNum);
|
||||||
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
|
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
|
||||||
} else {
|
} else {
|
||||||
@@ -1426,13 +1477,9 @@ void menuHandler::manageNodeMenu()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (n->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) {
|
const bool wasMuted = nodeInfoLiteIsMuted(n);
|
||||||
n->bitfield &= ~NODEINFO_BITFIELD_IS_MUTED_MASK;
|
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
|
||||||
LOG_INFO("Unmuted node %08X", menuHandler::pickedNodeNum);
|
LOG_INFO(wasMuted ? "Unmuted node %08X" : "Muted node %08X", menuHandler::pickedNodeNum);
|
||||||
} else {
|
|
||||||
n->bitfield |= NODEINFO_BITFIELD_IS_MUTED_MASK;
|
|
||||||
LOG_INFO("Muted node %08X", menuHandler::pickedNodeNum);
|
|
||||||
}
|
|
||||||
nodeDB->notifyObservers(true);
|
nodeDB->notifyObservers(true);
|
||||||
nodeDB->saveToDisk();
|
nodeDB->saveToDisk();
|
||||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
||||||
@@ -1461,11 +1508,11 @@ void menuHandler::manageNodeMenu()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (n->is_ignored) {
|
if (nodeInfoLiteIsIgnored(n)) {
|
||||||
n->is_ignored = false;
|
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
|
||||||
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
|
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
|
||||||
} else {
|
} else {
|
||||||
n->is_ignored = true;
|
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||||
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
|
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
|
||||||
}
|
}
|
||||||
nodeDB->notifyObservers(true);
|
nodeDB->notifyObservers(true);
|
||||||
@@ -2079,9 +2126,9 @@ void menuHandler::removeFavoriteMenu()
|
|||||||
static const char *optionsArray[] = {"Back", "Yes"};
|
static const char *optionsArray[] = {"Back", "Yes"};
|
||||||
BannerOverlayOptions bannerOptions;
|
BannerOverlayOptions bannerOptions;
|
||||||
std::string message = "Unfavorite This Node?\n";
|
std::string message = "Unfavorite This Node?\n";
|
||||||
auto node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum);
|
const auto *node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum);
|
||||||
if (node && node->has_user) {
|
if (nodeInfoLiteHasUser(node)) {
|
||||||
message += sanitizeString(node->user.long_name).substr(0, 15);
|
message += sanitizeString(node->long_name).substr(0, 15);
|
||||||
}
|
}
|
||||||
bannerOptions.message = message.c_str();
|
bannerOptions.message = message.c_str();
|
||||||
bannerOptions.optionsArrayPtr = optionsArray;
|
bannerOptions.optionsArrayPtr = optionsArray;
|
||||||
|
|||||||
@@ -462,8 +462,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
}
|
}
|
||||||
case ThreadMode::DIRECT: {
|
case ThreadMode::DIRECT: {
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
|
||||||
if (node && node->has_user && node->user.short_name[0]) {
|
if (nodeInfoLiteHasUser(node) && node->short_name[0]) {
|
||||||
snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name);
|
snprintf(titleStr, sizeof(titleStr), "@%s", node->short_name);
|
||||||
} else {
|
} else {
|
||||||
snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
|
snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
|
||||||
}
|
}
|
||||||
@@ -585,11 +585,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
|
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
|
||||||
|
|
||||||
char senderName[64] = "";
|
char senderName[64] = "";
|
||||||
if (node && node->has_user) {
|
if (nodeInfoLiteHasUser(node)) {
|
||||||
if (node->user.long_name[0]) {
|
if (node->long_name[0]) {
|
||||||
strncpy(senderName, node->user.long_name, sizeof(senderName) - 1);
|
strncpy(senderName, node->long_name, sizeof(senderName) - 1);
|
||||||
} else if (node->user.short_name[0]) {
|
} else if (node->short_name[0]) {
|
||||||
strncpy(senderName, node->user.short_name, sizeof(senderName) - 1);
|
strncpy(senderName, node->short_name, sizeof(senderName) - 1);
|
||||||
}
|
}
|
||||||
senderName[sizeof(senderName) - 1] = '\0';
|
senderName[sizeof(senderName) - 1] = '\0';
|
||||||
}
|
}
|
||||||
@@ -599,18 +599,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
|
|
||||||
// If this is *our own* message, override senderName to who the recipient was
|
// If this is *our own* message, override senderName to who the recipient was
|
||||||
bool mine = (m.sender == nodeDB->getNodeNum());
|
bool mine = (m.sender == nodeDB->getNodeNum());
|
||||||
if (mine && node_recipient && node_recipient->has_user) {
|
if (mine && nodeInfoLiteHasUser(node_recipient)) {
|
||||||
if (node_recipient->user.long_name[0]) {
|
if (node_recipient->long_name[0]) {
|
||||||
strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1);
|
strncpy(senderName, node_recipient->long_name, sizeof(senderName) - 1);
|
||||||
senderName[sizeof(senderName) - 1] = '\0';
|
senderName[sizeof(senderName) - 1] = '\0';
|
||||||
} else if (node_recipient->user.short_name[0]) {
|
} else if (node_recipient->short_name[0]) {
|
||||||
strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1);
|
strncpy(senderName, node_recipient->short_name, sizeof(senderName) - 1);
|
||||||
senderName[sizeof(senderName) - 1] = '\0';
|
senderName[sizeof(senderName) - 1] = '\0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
|
// If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
|
||||||
if (mine && (!node_recipient || !node_recipient->has_user ||
|
if (mine && (!nodeInfoLiteHasUser(node_recipient) || (!node_recipient->long_name[0] && !node_recipient->short_name[0]))) {
|
||||||
(!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) {
|
|
||||||
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
|
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,12 +1072,12 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
|||||||
// Banner logic
|
// Banner logic
|
||||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
|
||||||
char longName[64] = "?";
|
char longName[64] = "?";
|
||||||
if (node && node->has_user) {
|
if (nodeInfoLiteHasUser(node)) {
|
||||||
if (node->user.long_name[0]) {
|
if (node->long_name[0]) {
|
||||||
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
|
strncpy(longName, node->long_name, sizeof(longName) - 1);
|
||||||
longName[sizeof(longName) - 1] = '\0';
|
longName[sizeof(longName) - 1] = '\0';
|
||||||
} else if (node->user.short_name[0]) {
|
} else if (node->short_name[0]) {
|
||||||
strncpy(longName, node->user.short_name, sizeof(longName) - 1);
|
strncpy(longName, node->short_name, sizeof(longName) - 1);
|
||||||
longName[sizeof(longName) - 1] = '\0';
|
longName[sizeof(longName) - 1] = '\0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,29 +98,23 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
|||||||
// 1) Choose target candidate (long vs short) only if present
|
// 1) Choose target candidate (long vs short) only if present
|
||||||
const char *raw = nullptr;
|
const char *raw = nullptr;
|
||||||
|
|
||||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
#if !MESHTASTIC_EXCLUDE_STATUS && !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
// If long-name mode is enabled, and we have a recent status for this node,
|
// If long-name mode is enabled, and we have a recent status for this node,
|
||||||
// prefer "(short_name) statusText" as the raw candidate.
|
// prefer "(short_name) statusText" as the raw candidate. Pull straight out
|
||||||
|
// of NodeDB's per-NodeNum cache instead of scanning a FIFO.
|
||||||
std::string composedFromStatus;
|
std::string composedFromStatus;
|
||||||
if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) {
|
if (config.display.use_long_node_name && nodeInfoLiteHasUser(node) && nodeDB) {
|
||||||
const auto &recent = statusMessageModule->getRecentReceived();
|
meshtastic_StatusMessage cachedStatus;
|
||||||
const StatusMessageModule::RecentStatus *found = nullptr;
|
if (nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) {
|
||||||
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
|
const char *shortName = node->short_name;
|
||||||
if (it->fromNodeId == node->num && !it->statusText.empty()) {
|
const size_t statusLen = std::strlen(cachedStatus.status);
|
||||||
found = &(*it);
|
composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + statusLen);
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
const char *shortName = node->user.short_name;
|
|
||||||
composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size());
|
|
||||||
composedFromStatus += "(";
|
composedFromStatus += "(";
|
||||||
if (shortName && *shortName) {
|
if (shortName && *shortName) {
|
||||||
composedFromStatus += shortName;
|
composedFromStatus += shortName;
|
||||||
}
|
}
|
||||||
composedFromStatus += ") ";
|
composedFromStatus += ") ";
|
||||||
composedFromStatus += found->statusText;
|
composedFromStatus += cachedStatus.status;
|
||||||
|
|
||||||
raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string
|
raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string
|
||||||
}
|
}
|
||||||
@@ -129,8 +123,8 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
|||||||
|
|
||||||
// If we didn't compose from status, use normal long/short selection
|
// If we didn't compose from status, use normal long/short selection
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
if (node && node->has_user) {
|
if (nodeInfoLiteHasUser(node)) {
|
||||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
raw = config.display.use_long_node_name ? node->long_name : node->short_name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +212,7 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
|
|||||||
static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName,
|
static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName,
|
||||||
int16_t nameX, int16_t y, int nameMaxWidth)
|
int16_t nameX, int16_t y, int nameMaxWidth)
|
||||||
{
|
{
|
||||||
if (!display || !node || !node->is_favorite || !isTFTColoringEnabled() || !nodeName) {
|
if (!display || !node || !nodeInfoLiteIsFavorite(node) || !isTFTColoringEnabled() || !nodeName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +253,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
|||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||||
#endif
|
#endif
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||||
|
|
||||||
char timeStr[10];
|
char timeStr[10];
|
||||||
uint32_t seconds = sinceLastSeen(node);
|
uint32_t seconds = sinceLastSeen(node);
|
||||||
@@ -279,14 +273,14 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
|||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
display->setFont(FONT_SMALL);
|
display->setFont(FONT_SMALL);
|
||||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||||
if (node->is_favorite) {
|
if (nodeInfoLiteIsFavorite(node)) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||||
} else {
|
} else {
|
||||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node->is_ignored || isMuted) {
|
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||||
} else {
|
} else {
|
||||||
@@ -321,20 +315,20 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
|||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||||
#endif
|
#endif
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||||
|
|
||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
display->setFont(FONT_SMALL);
|
display->setFont(FONT_SMALL);
|
||||||
|
|
||||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||||
if (node->is_favorite) {
|
if (nodeInfoLiteIsFavorite(node)) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||||
} else {
|
} else {
|
||||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node->is_ignored || isMuted) {
|
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||||
} else {
|
} else {
|
||||||
@@ -401,15 +395,19 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||||
#endif
|
#endif
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||||
char distStr[10] = "";
|
char distStr[10] = "";
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
meshtastic_PositionLite ourPos;
|
||||||
double lat1 = ourNode->position.latitude_i * 1e-7;
|
meshtastic_PositionLite theirPos;
|
||||||
double lon1 = ourNode->position.longitude_i * 1e-7;
|
const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos);
|
||||||
double lat2 = node->position.latitude_i * 1e-7;
|
const bool haveTheirPos = nodeDB->copyNodePosition(node->num, theirPos);
|
||||||
double lon2 = node->position.longitude_i * 1e-7;
|
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node) && haveOurPos && haveTheirPos) {
|
||||||
|
double lat1 = ourPos.latitude_i * 1e-7;
|
||||||
|
double lon1 = ourPos.longitude_i * 1e-7;
|
||||||
|
double lat2 = theirPos.latitude_i * 1e-7;
|
||||||
|
double lon2 = theirPos.longitude_i * 1e-7;
|
||||||
|
|
||||||
double earthRadiusKm = 6371.0;
|
double earthRadiusKm = 6371.0;
|
||||||
double dLat = (lat2 - lat1) * DEG_TO_RAD;
|
double dLat = (lat2 - lat1) * DEG_TO_RAD;
|
||||||
@@ -455,14 +453,14 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
display->setFont(FONT_SMALL);
|
display->setFont(FONT_SMALL);
|
||||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||||
if (node->is_favorite) {
|
if (nodeInfoLiteIsFavorite(node)) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||||
} else {
|
} else {
|
||||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node->is_ignored || isMuted) {
|
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||||
} else {
|
} else {
|
||||||
@@ -509,19 +507,19 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||||
#endif
|
#endif
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||||
|
|
||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
display->setFont(FONT_SMALL);
|
display->setFont(FONT_SMALL);
|
||||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||||
if (node->is_favorite) {
|
if (nodeInfoLiteIsFavorite(node)) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||||
} else {
|
} else {
|
||||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node->is_ignored || isMuted) {
|
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||||
} else {
|
} else {
|
||||||
@@ -542,8 +540,11 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
int centerX = x + columnWidth - arrowXOffset;
|
int centerX = x + columnWidth - arrowXOffset;
|
||||||
int centerY = y + FONT_HEIGHT_SMALL / 2;
|
int centerY = y + FONT_HEIGHT_SMALL / 2;
|
||||||
|
|
||||||
double nodeLat = node->position.latitude_i * 1e-7;
|
meshtastic_PositionLite nodePos;
|
||||||
double nodeLon = node->position.longitude_i * 1e-7;
|
if (!nodeDB->copyNodePosition(node->num, nodePos))
|
||||||
|
return;
|
||||||
|
double nodeLat = nodePos.latitude_i * 1e-7;
|
||||||
|
double nodeLon = nodePos.longitude_i * 1e-7;
|
||||||
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
|
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
|
||||||
float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian);
|
float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian);
|
||||||
float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing);
|
float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing);
|
||||||
@@ -652,7 +653,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
|||||||
continue;
|
continue;
|
||||||
if (n->num == nodeDB->getNodeNum())
|
if (n->num == nodeDB->getNodeNum())
|
||||||
continue;
|
continue;
|
||||||
if (locationScreen && !n->has_position)
|
if (locationScreen && !nodeDB->hasNodePosition(n->num))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
drawList.push_back(n->num);
|
drawList.push_back(n->num);
|
||||||
@@ -883,14 +884,15 @@ void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
|||||||
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||||
{
|
{
|
||||||
float headingRadian = 0.0f;
|
float headingRadian = 0.0f;
|
||||||
auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const auto *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
if (!ourNode || !nodeDB->hasValidPosition(ourNode)) {
|
meshtastic_PositionLite ourSelfPos;
|
||||||
|
if (!ourNode || !nodeDB->hasValidPosition(ourNode) || !nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
|
||||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0);
|
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
double lat = DegD(ourNode->position.latitude_i);
|
double lat = DegD(ourSelfPos.latitude_i);
|
||||||
double lon = DegD(ourNode->position.longitude_i);
|
double lon = DegD(ourSelfPos.longitude_i);
|
||||||
|
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(M5STACK_UNITC6L)
|
||||||
display->clear();
|
display->clear();
|
||||||
|
|||||||
@@ -317,12 +317,12 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
|
|||||||
for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) {
|
for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) {
|
||||||
char tempName[48] = {0};
|
char tempName[48] = {0};
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1);
|
||||||
if (node && node->has_user) {
|
if (nodeInfoLiteHasUser(node)) {
|
||||||
const char *rawName = nullptr;
|
const char *rawName = nullptr;
|
||||||
if (node->user.long_name[0]) {
|
if (node->long_name[0]) {
|
||||||
rawName = node->user.long_name;
|
rawName = node->long_name;
|
||||||
} else if (node->user.short_name[0]) {
|
} else if (node->short_name[0]) {
|
||||||
rawName = node->user.short_name;
|
rawName = node->short_name;
|
||||||
}
|
}
|
||||||
if (rawName) {
|
if (rawName) {
|
||||||
const int arrowWidth = (currentResolution == ScreenResolution::High)
|
const int arrowWidth = (currentResolution == ScreenResolution::High)
|
||||||
|
|||||||
@@ -464,11 +464,11 @@ static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const std::string &statusText)
|
static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const char *statusText)
|
||||||
{
|
{
|
||||||
// Fixed-buffer truncate helper replaces iterative std::string chopping to keep code size down.
|
// Fixed-buffer truncate helper replaces iterative std::string chopping to keep code size down.
|
||||||
char rawStatus[96];
|
char rawStatus[96];
|
||||||
snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText.c_str());
|
snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText ? statusText : "");
|
||||||
|
|
||||||
char clippedStatus[96];
|
char clippedStatus[96];
|
||||||
UIRenderer::truncateStringWithEmotes(display, rawStatus, clippedStatus, sizeof(clippedStatus), display->getWidth());
|
UIRenderer::truncateStringWithEmotes(display, rawStatus, clippedStatus, sizeof(clippedStatus), display->getWidth());
|
||||||
@@ -495,7 +495,7 @@ void graphics::UIRenderer::rebuildFavoritedNodes()
|
|||||||
meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||||||
if (!n || n->num == nodeDB->getNodeNum())
|
if (!n || n->num == nodeDB->getNodeNum())
|
||||||
continue;
|
continue;
|
||||||
if (n->is_favorite)
|
if (nodeInfoLiteIsFavorite(n))
|
||||||
favoritedNodes.push_back(n);
|
favoritedNodes.push_back(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +750,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex];
|
meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex];
|
||||||
if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite)
|
if (!node || node->num == nodeDB->getNodeNum() || !nodeInfoLiteIsFavorite(node))
|
||||||
return;
|
return;
|
||||||
display->clear();
|
display->clear();
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(M5STACK_UNITC6L)
|
||||||
@@ -763,7 +763,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
|||||||
#endif
|
#endif
|
||||||
currentFavoriteNodeNum = node->num;
|
currentFavoriteNodeNum = node->num;
|
||||||
// === Create the shortName and title string ===
|
// === Create the shortName and title string ===
|
||||||
const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node";
|
const char *shortName = (nodeInfoLiteHasUser(node) && node->short_name[0]) ? node->short_name : "Node";
|
||||||
char titlestr[40];
|
char titlestr[40];
|
||||||
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
|
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
|
||||||
|
|
||||||
@@ -781,9 +781,9 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
|||||||
// === 1. Long Name (always try to show first) ===
|
// === 1. Long Name (always try to show first) ===
|
||||||
const char *username;
|
const char *username;
|
||||||
if (currentResolution == ScreenResolution::UltraLow) {
|
if (currentResolution == ScreenResolution::UltraLow) {
|
||||||
username = (node->has_user && node->user.long_name[0]) ? node->user.short_name : nullptr;
|
username = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->short_name : nullptr;
|
||||||
} else {
|
} else {
|
||||||
username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
|
username = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print node's long name (e.g. "Backpack Node")
|
// Print node's long name (e.g. "Backpack Node")
|
||||||
@@ -796,23 +796,14 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
|||||||
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
|
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
#if !MESHTASTIC_EXCLUDE_STATUS && !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
// === Optional: Last received StatusMessage line for this node ===
|
// === Optional: Last received StatusMessage line for this node ===
|
||||||
// Display it directly under the username line (if we have one).
|
// Display it directly under the username line (if we have one). The cache
|
||||||
if (statusMessageModule) {
|
// lives on NodeDB now, keyed by NodeNum, so this is an O(1) lookup.
|
||||||
const auto &recent = statusMessageModule->getRecentReceived();
|
if (nodeDB) {
|
||||||
const StatusMessageModule::RecentStatus *found = nullptr;
|
meshtastic_StatusMessage cachedStatus;
|
||||||
|
if (nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) {
|
||||||
// Search newest-to-oldest
|
drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], cachedStatus.status);
|
||||||
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
|
|
||||||
if (it->fromNodeId == node->num && !it->statusText.empty()) {
|
|
||||||
found = &(*it);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], found->statusText);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -971,25 +962,30 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
|||||||
#if !defined(M5STACK_UNITC6L)
|
#if !defined(M5STACK_UNITC6L)
|
||||||
// === 4. Uptime (only show if metric is present) ===
|
// === 4. Uptime (only show if metric is present) ===
|
||||||
char uptimeStr[32] = "";
|
char uptimeStr[32] = "";
|
||||||
if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
|
meshtastic_DeviceMetrics nodeMetrics;
|
||||||
|
const bool haveNodeMetrics = nodeDB->copyNodeTelemetry(node->num, nodeMetrics);
|
||||||
|
if (haveNodeMetrics && nodeMetrics.has_uptime_seconds) {
|
||||||
char upPrefix[12]; // enough for leftSideSpacing + "Up:"
|
char upPrefix[12]; // enough for leftSideSpacing + "Up:"
|
||||||
snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing);
|
snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing);
|
||||||
getUptimeStr(node->device_metrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr));
|
getUptimeStr(nodeMetrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr));
|
||||||
}
|
}
|
||||||
if (uptimeStr[0]) {
|
if (uptimeStr[0]) {
|
||||||
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
|
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 5. Distance (only if both nodes have GPS position) ===
|
// === 5. Distance (only if both nodes have GPS position) ===
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
char distStr[24] = ""; // Make buffer big enough for any string
|
char distStr[24] = ""; // Make buffer big enough for any string
|
||||||
bool haveDistance = false;
|
bool haveDistance = false;
|
||||||
|
|
||||||
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
meshtastic_PositionLite nodePos;
|
||||||
|
meshtastic_PositionLite ourPos;
|
||||||
|
const bool haveNodePos = nodeDB->copyNodePosition(node->num, nodePos);
|
||||||
|
const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos);
|
||||||
|
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node) && haveNodePos && haveOurPos) {
|
||||||
// Use shared meter conversion, then format display units with lightweight integer rounding.
|
// Use shared meter conversion, then format display units with lightweight integer rounding.
|
||||||
const float distanceMeters =
|
const float distanceMeters = GeoCoord::latLongToMeter(DegD(nodePos.latitude_i), DegD(nodePos.longitude_i),
|
||||||
GeoCoord::latLongToMeter(DegD(node->position.latitude_i), DegD(node->position.longitude_i),
|
DegD(ourPos.latitude_i), DegD(ourPos.longitude_i));
|
||||||
DegD(ourNode->position.latitude_i), DegD(ourNode->position.longitude_i));
|
|
||||||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
|
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
|
||||||
const int feet = static_cast<int>((distanceMeters * METERS_TO_FEET) + 0.5f);
|
const int feet = static_cast<int>((distanceMeters * METERS_TO_FEET) + 0.5f);
|
||||||
if (feet > 0 && feet < 1000) {
|
if (feet > 0 && feet < 1000) {
|
||||||
@@ -1024,19 +1020,19 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
|||||||
char batLine[32] = "";
|
char batLine[32] = "";
|
||||||
bool haveBatLine = false;
|
bool haveBatLine = false;
|
||||||
|
|
||||||
if (node->has_device_metrics) {
|
if (haveNodeMetrics) {
|
||||||
bool hasPct = node->device_metrics.has_battery_level;
|
bool hasPct = nodeMetrics.has_battery_level;
|
||||||
bool hasVolt = node->device_metrics.has_voltage && node->device_metrics.voltage > 0.001f;
|
bool hasVolt = nodeMetrics.has_voltage && nodeMetrics.voltage > 0.001f;
|
||||||
|
|
||||||
int pct = 0;
|
int pct = 0;
|
||||||
float volt = 0.0f;
|
float volt = 0.0f;
|
||||||
|
|
||||||
if (hasPct) {
|
if (hasPct) {
|
||||||
pct = (int)node->device_metrics.battery_level;
|
pct = (int)nodeMetrics.battery_level;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVolt) {
|
if (hasVolt) {
|
||||||
volt = node->device_metrics.voltage;
|
volt = nodeMetrics.voltage;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPct && pct > 0 && pct <= 100) {
|
if (hasPct && pct > 0 && pct <= 100) {
|
||||||
@@ -1076,11 +1072,11 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
|||||||
const bool hasNodePositionFix = nodeDB->hasValidPosition(node);
|
const bool hasNodePositionFix = nodeDB->hasValidPosition(node);
|
||||||
const char *statusLine1 = nullptr;
|
const char *statusLine1 = nullptr;
|
||||||
const char *statusLine2 = nullptr;
|
const char *statusLine2 = nullptr;
|
||||||
if (hasOwnPositionFix && hasNodePositionFix) {
|
if (hasOwnPositionFix && hasNodePositionFix && haveOurPos && haveNodePos) {
|
||||||
const auto &op = ourNode->position;
|
const auto &op = ourPos;
|
||||||
showCompass = CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading);
|
showCompass = CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading);
|
||||||
if (showCompass) {
|
if (showCompass) {
|
||||||
const auto &p = node->position;
|
const auto &p = nodePos;
|
||||||
bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||||||
bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading);
|
bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading);
|
||||||
} else {
|
} else {
|
||||||
@@ -1130,7 +1126,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
|||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
display->setFont(FONT_SMALL);
|
display->setFont(FONT_SMALL);
|
||||||
int line = 1;
|
int line = 1;
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
|
|
||||||
// === Header ===
|
// === Header ===
|
||||||
if (currentResolution == ScreenResolution::UltraLow) {
|
if (currentResolution == ScreenResolution::UltraLow) {
|
||||||
@@ -1270,7 +1266,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
|||||||
int textWidth = 0;
|
int textWidth = 0;
|
||||||
int nameX = 0;
|
int nameX = 0;
|
||||||
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
|
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
|
||||||
const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : "";
|
const char *longName = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : "";
|
||||||
const char *shortName = owner.short_name ? owner.short_name : "";
|
const char *shortName = owner.short_name ? owner.short_name : "";
|
||||||
char combinedName[96];
|
char combinedName[96];
|
||||||
if (longName[0] && shortName[0]) {
|
if (longName[0] && shortName[0]) {
|
||||||
@@ -1597,7 +1593,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
|||||||
geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
|
geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
|
||||||
int32_t(gpsStatus->getAltitude()));
|
int32_t(gpsStatus->getAltitude()));
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
|
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
|
||||||
const bool hasLiveGpsFix =
|
const bool hasLiveGpsFix =
|
||||||
(gpsStatus && gpsStatus->getHasLock() && (gpsStatus->getLatitude() != 0 || gpsStatus->getLongitude() != 0));
|
(gpsStatus && gpsStatus->getHasLock() && (gpsStatus->getLatitude() != 0 || gpsStatus->getLongitude() != 0));
|
||||||
@@ -1613,9 +1609,11 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
|||||||
headingLat = DegD(gpsStatus->getLatitude());
|
headingLat = DegD(gpsStatus->getLatitude());
|
||||||
headingLon = DegD(gpsStatus->getLongitude());
|
headingLon = DegD(gpsStatus->getLongitude());
|
||||||
} else if (hasOwnPositionFix) {
|
} else if (hasOwnPositionFix) {
|
||||||
const auto &op = ourNode->position;
|
meshtastic_PositionLite ownPos;
|
||||||
headingLat = DegD(op.latitude_i);
|
if (nodeDB->copyNodePosition(ourNode->num, ownPos)) {
|
||||||
headingLon = DegD(op.longitude_i);
|
headingLat = DegD(ownPos.latitude_i);
|
||||||
|
headingLon = DegD(ownPos.longitude_i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
validHeading = CompassRenderer::getHeadingRadians(headingLat, headingLon, heading);
|
validHeading = CompassRenderer::getHeadingRadians(headingLat, headingLon, heading);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,8 +362,8 @@ std::string InkHUD::Applet::parseShortName(meshtastic_NodeInfoLite *node)
|
|||||||
assert(node);
|
assert(node);
|
||||||
|
|
||||||
// Use the true shortname if known, and doesn't contain any unprintable characters (emoji, etc.)
|
// Use the true shortname if known, and doesn't contain any unprintable characters (emoji, etc.)
|
||||||
if (node->has_user) {
|
if (nodeInfoLiteHasUser(node)) {
|
||||||
std::string parsed = parse(node->user.short_name);
|
std::string parsed = parse(node->short_name);
|
||||||
if (isPrintable(parsed))
|
if (isPrintable(parsed))
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,9 +136,10 @@ void InkHUD::MapApplet::onRender(bool full)
|
|||||||
printAt(vertBarX + (bottomLabelW / 2) + 1, bottomLabelY + (bottomLabelH / 2), vertBottomLabel, CENTER, MIDDLE);
|
printAt(vertBarX + (bottomLabelW / 2) + 1, bottomLabelY + (bottomLabelH / 2), vertBottomLabel, CENTER, MIDDLE);
|
||||||
|
|
||||||
// Draw our node LAST with full white fill + outline
|
// Draw our node LAST with full white fill + outline
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
if (ourNode && nodeDB->hasValidPosition(ourNode)) {
|
meshtastic_PositionLite ourSelfPos;
|
||||||
Marker self = calculateMarker(ourNode->position.latitude_i * 1e-7, ourNode->position.longitude_i * 1e-7, false, 0);
|
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
|
||||||
|
Marker self = calculateMarker(ourSelfPos.latitude_i * 1e-7, ourSelfPos.longitude_i * 1e-7, false, 0);
|
||||||
|
|
||||||
int16_t centerX = X(0.5) + (self.eastMeters * metersToPx);
|
int16_t centerX = X(0.5) + (self.eastMeters * metersToPx);
|
||||||
int16_t centerY = Y(0.5) - (self.northMeters * metersToPx);
|
int16_t centerY = Y(0.5) - (self.northMeters * metersToPx);
|
||||||
@@ -168,10 +169,11 @@ void InkHUD::MapApplet::onRender(bool full)
|
|||||||
void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
|
void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
|
||||||
{
|
{
|
||||||
// If we have a valid position for our own node, use that as the anchor
|
// If we have a valid position for our own node, use that as the anchor
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
if (ourNode && nodeDB->hasValidPosition(ourNode)) {
|
meshtastic_PositionLite ourSelfPos;
|
||||||
*lat = ourNode->position.latitude_i * 1e-7;
|
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
|
||||||
*lng = ourNode->position.longitude_i * 1e-7;
|
*lat = ourSelfPos.latitude_i * 1e-7;
|
||||||
|
*lng = ourSelfPos.longitude_i * 1e-7;
|
||||||
} else {
|
} else {
|
||||||
// Find mean lat long coords
|
// Find mean lat long coords
|
||||||
// ============================
|
// ============================
|
||||||
@@ -201,9 +203,13 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
|
|||||||
if (!shouldDrawNode(node))
|
if (!shouldDrawNode(node))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
meshtastic_PositionLite pos;
|
||||||
|
if (!nodeDB->copyNodePosition(node->num, pos))
|
||||||
|
continue;
|
||||||
|
|
||||||
// Latitude and Longitude of node, in radians
|
// Latitude and Longitude of node, in radians
|
||||||
float latRad = node->position.latitude_i * (1e-7) * DEG_TO_RAD;
|
float latRad = pos.latitude_i * (1e-7) * DEG_TO_RAD;
|
||||||
float lngRad = node->position.longitude_i * (1e-7) * DEG_TO_RAD;
|
float lngRad = pos.longitude_i * (1e-7) * DEG_TO_RAD;
|
||||||
|
|
||||||
// Convert to cartesian points, with center of earth at 0, 0, 0
|
// Convert to cartesian points, with center of earth at 0, 0, 0
|
||||||
// Exact distance from center is irrelevant, as we're only interested in the vector
|
// Exact distance from center is irrelevant, as we're only interested in the vector
|
||||||
@@ -300,13 +306,17 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
|
|||||||
if (!shouldDrawNode(node))
|
if (!shouldDrawNode(node))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
meshtastic_PositionLite pos;
|
||||||
|
if (!nodeDB->copyNodePosition(node->num, pos))
|
||||||
|
continue;
|
||||||
|
|
||||||
// Check for a new top or bottom latitude
|
// Check for a new top or bottom latitude
|
||||||
float latNode = node->position.latitude_i * 1e-7;
|
float latNode = pos.latitude_i * 1e-7;
|
||||||
northernmost = max(northernmost, latNode);
|
northernmost = max(northernmost, latNode);
|
||||||
southernmost = min(southernmost, latNode);
|
southernmost = min(southernmost, latNode);
|
||||||
|
|
||||||
// Longitude is trickier
|
// Longitude is trickier
|
||||||
float lngNode = node->position.longitude_i * 1e-7;
|
float lngNode = pos.longitude_i * 1e-7;
|
||||||
float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees traveled east from lngCenter to reach node
|
float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees traveled east from lngCenter to reach node
|
||||||
float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees traveled west from lngCenter to reach node
|
float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees traveled west from lngCenter to reach node
|
||||||
if (degEastward < degWestward)
|
if (degEastward < degWestward)
|
||||||
@@ -372,10 +382,13 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
|
|||||||
{
|
{
|
||||||
// Find x and y position based on node's position in nodeDB
|
// Find x and y position based on node's position in nodeDB
|
||||||
assert(nodeDB->hasValidPosition(node));
|
assert(nodeDB->hasValidPosition(node));
|
||||||
Marker m = calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
meshtastic_PositionLite pos;
|
||||||
node->position.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
const bool hasPos = nodeDB->copyNodePosition(node->num, pos);
|
||||||
node->has_hops_away, // Is the hopsAway number valid
|
assert(hasPos);
|
||||||
node->hops_away // Hops away
|
Marker m = calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
||||||
|
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
||||||
|
node->has_hops_away, // Is the hopsAway number valid
|
||||||
|
node->hops_away // Hops away
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to pixel coords
|
// Convert to pixel coords
|
||||||
@@ -516,13 +529,16 @@ void InkHUD::MapApplet::calculateAllMarkers()
|
|||||||
if (node->num == nodeDB->getNodeNum())
|
if (node->num == nodeDB->getNodeNum())
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
meshtastic_PositionLite pos;
|
||||||
|
if (!nodeDB->copyNodePosition(node->num, pos))
|
||||||
|
continue;
|
||||||
|
|
||||||
// Calculate marker and store it
|
// Calculate marker and store it
|
||||||
markers.push_back(
|
markers.push_back(calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
||||||
calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
||||||
node->position.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
node->has_hops_away, // Is the hopsAway number valid
|
||||||
node->has_hops_away, // Is the hopsAway number valid
|
node->hops_away // Hops away
|
||||||
node->hops_away // Hops away
|
));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,21 +50,23 @@ ProcessMessage InkHUD::NodeListApplet::handleReceived(const meshtastic_MeshPacke
|
|||||||
c.signal = getSignalStrength(mp.rx_snr, mp.rx_rssi);
|
c.signal = getSignalStrength(mp.rx_snr, mp.rx_rssi);
|
||||||
|
|
||||||
// Assemble info: from nodeDB (needed to detect changes)
|
// Assemble info: from nodeDB (needed to detect changes)
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(c.nodeNum);
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(c.nodeNum);
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
if (node) {
|
if (node) {
|
||||||
if (node->has_hops_away)
|
if (node->has_hops_away)
|
||||||
c.hopsAway = node->hops_away;
|
c.hopsAway = node->hops_away;
|
||||||
|
|
||||||
if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) {
|
if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) {
|
||||||
// Get lat and long as float
|
meshtastic_PositionLite ourPos;
|
||||||
// Meshtastic stores these as integers internally
|
meshtastic_PositionLite theirPos;
|
||||||
float ourLat = ourNode->position.latitude_i * 1e-7;
|
if (nodeDB->copyNodePosition(ourNode->num, ourPos) && nodeDB->copyNodePosition(node->num, theirPos)) {
|
||||||
float ourLong = ourNode->position.longitude_i * 1e-7;
|
float ourLat = ourPos.latitude_i * 1e-7;
|
||||||
float theirLat = node->position.latitude_i * 1e-7;
|
float ourLong = ourPos.longitude_i * 1e-7;
|
||||||
float theirLong = node->position.longitude_i * 1e-7;
|
float theirLat = theirPos.latitude_i * 1e-7;
|
||||||
|
float theirLong = theirPos.longitude_i * 1e-7;
|
||||||
|
|
||||||
c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong);
|
c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +181,8 @@ void InkHUD::NodeListApplet::onRender(bool full)
|
|||||||
// -- Longname --
|
// -- Longname --
|
||||||
// Parse special chars in long name
|
// Parse special chars in long name
|
||||||
// Use node id if unknown
|
// Use node id if unknown
|
||||||
if (node && node->has_user)
|
if (nodeInfoLiteHasUser(node))
|
||||||
longName = parse(node->user.long_name); // Found in nodeDB
|
longName = parse(node->long_name); // Found in nodeDB
|
||||||
else {
|
else {
|
||||||
// Not found in nodeDB, show a hex nodeid instead
|
// Not found in nodeDB, show a hex nodeid instead
|
||||||
longName = hexifyNodeNum(nodeNum);
|
longName = hexifyNodeNum(nodeNum);
|
||||||
|
|||||||
@@ -2090,7 +2090,7 @@ void InkHUD::MenuApplet::populateRecipientPage()
|
|||||||
|
|
||||||
// Count favorites
|
// Count favorites
|
||||||
for (uint32_t i = 0; i < nodeCount; i++) {
|
for (uint32_t i = 0; i < nodeCount; i++) {
|
||||||
if (nodeDB->getMeshNodeByIndex(i)->is_favorite)
|
if (nodeInfoLiteIsFavorite(nodeDB->getMeshNodeByIndex(i)))
|
||||||
favoriteCount++;
|
favoriteCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2098,10 +2098,10 @@ void InkHUD::MenuApplet::populateRecipientPage()
|
|||||||
// Don't want some monstrous list that takes 100 clicks to reach exit
|
// Don't want some monstrous list that takes 100 clicks to reach exit
|
||||||
if (favoriteCount < 20) {
|
if (favoriteCount < 20) {
|
||||||
for (uint32_t i = 0; i < nodeCount; i++) {
|
for (uint32_t i = 0; i < nodeCount; i++) {
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
||||||
|
|
||||||
// Skip node if not a favorite
|
// Skip node if not a favorite
|
||||||
if (!node->is_favorite)
|
if (!nodeInfoLiteIsFavorite(node))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
CannedMessages::RecipientItem r;
|
CannedMessages::RecipientItem r;
|
||||||
@@ -2111,8 +2111,8 @@ void InkHUD::MenuApplet::populateRecipientPage()
|
|||||||
|
|
||||||
// Set a label for the menu item
|
// Set a label for the menu item
|
||||||
r.label = "DM: ";
|
r.label = "DM: ";
|
||||||
if (node->has_user)
|
if (nodeInfoLiteHasUser(node))
|
||||||
r.label += parse(node->user.long_name);
|
r.label += parse(node->long_name);
|
||||||
else
|
else
|
||||||
r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo?
|
r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo?
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
|||||||
text += msgIsBroadcast ? "From:" : "DM: ";
|
text += msgIsBroadcast ? "From:" : "DM: ";
|
||||||
|
|
||||||
// Sender id
|
// Sender id
|
||||||
if (node && node->has_user)
|
if (nodeInfoLiteHasUser(node))
|
||||||
text += parseShortName(node);
|
text += parseShortName(node);
|
||||||
else
|
else
|
||||||
text += hexifyNodeNum(message->sender);
|
text += hexifyNodeNum(message->sender);
|
||||||
@@ -255,7 +255,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
|||||||
text += msgIsBroadcast ? "Msg from " : "DM from ";
|
text += msgIsBroadcast ? "Msg from " : "DM from ";
|
||||||
|
|
||||||
// Sender id
|
// Sender id
|
||||||
if (node && node->has_user)
|
if (nodeInfoLiteHasUser(node))
|
||||||
text += parseShortName(node);
|
text += parseShortName(node);
|
||||||
else
|
else
|
||||||
text += hexifyNodeNum(message->sender);
|
text += hexifyNodeNum(message->sender);
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ void InkHUD::AllMessageApplet::onRender(bool full)
|
|||||||
// - short name and long name, if available, or
|
// - short name and long name, if available, or
|
||||||
// - node id
|
// - node id
|
||||||
meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(message->sender);
|
meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(message->sender);
|
||||||
if (sender && sender->has_user) {
|
if (nodeInfoLiteHasUser(sender)) {
|
||||||
header += parseShortName(sender); // May be last-four of node if unprintable (emoji, etc)
|
header += parseShortName(sender); // May be last-four of node if unprintable (emoji, etc)
|
||||||
header += " (";
|
header += " (";
|
||||||
header += parse(sender->user.long_name);
|
header += parse(sender->long_name);
|
||||||
header += ")";
|
header += ")";
|
||||||
} else
|
} else
|
||||||
header += hexifyNodeNum(message->sender);
|
header += hexifyNodeNum(message->sender);
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
Shows the latest incoming text message, as well as sender.
|
Shows the latest incoming text message, as well as sender.
|
||||||
Both broadcast and direct messages will be shown here, from all channels.
|
Both broadcast and direct messages will be shown here, from all channels.
|
||||||
|
|
||||||
This module doesn't doesn't use the devicestate.rx_text_message,' as this is overwritten to contain outgoing messages
|
|
||||||
This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message.
|
This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message.
|
||||||
This is available to any interested modules (SingeMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage
|
This is available to any interested modules (SingleMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage
|
||||||
|
|
||||||
We do still receive notifications from the text message module though,
|
We do still receive notifications from the text message module though,
|
||||||
to know when a new message has arrived, and trigger the update.
|
to know when a new message has arrived, and trigger the update.
|
||||||
@@ -46,4 +45,4 @@ class AllMessageApplet : public Applet
|
|||||||
|
|
||||||
} // namespace NicheGraphics::InkHUD
|
} // namespace NicheGraphics::InkHUD
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ void InkHUD::DMApplet::onRender(bool full)
|
|||||||
// - shortname and long name, if available, or
|
// - shortname and long name, if available, or
|
||||||
// - node id
|
// - node id
|
||||||
meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(latestMessage->dm.sender);
|
meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(latestMessage->dm.sender);
|
||||||
if (sender && sender->has_user) {
|
if (nodeInfoLiteHasUser(sender)) {
|
||||||
header += parseShortName(sender); // May be last-four of node if unprintable (emoji, etc)
|
header += parseShortName(sender); // May be last-four of node if unprintable (emoji, etc)
|
||||||
header += " (";
|
header += " (";
|
||||||
header += parse(sender->user.long_name);
|
header += parse(sender->long_name);
|
||||||
header += ")";
|
header += ")";
|
||||||
} else
|
} else
|
||||||
header += hexifyNodeNum(latestMessage->dm.sender);
|
header += hexifyNodeNum(latestMessage->dm.sender);
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
/*
|
/*
|
||||||
|
|
||||||
Shows the latest incoming *Direct Message* (DM), as well as sender.
|
Shows the latest incoming *Direct Message* (DM), as well as sender.
|
||||||
This compliments the threaded message applets
|
This complements the threaded message applets
|
||||||
|
|
||||||
This module doesn't doesn't use the devicestate.rx_text_message,' as this is overwritten to contain outgoing messages
|
|
||||||
This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message.
|
This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message.
|
||||||
This is available to any interested modules (SingeMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage
|
This is available to any interested modules (SingleMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage
|
||||||
|
|
||||||
We do still receive notifications from the text message module though,
|
We do still receive notifications from the text message module though,
|
||||||
to know when a new message has arrived, and trigger the update.
|
to know when a new message has arrived, and trigger the update.
|
||||||
@@ -46,4 +45,4 @@ class DMApplet : public Applet
|
|||||||
|
|
||||||
} // namespace NicheGraphics::InkHUD
|
} // namespace NicheGraphics::InkHUD
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using namespace NicheGraphics;
|
|||||||
bool InkHUD::FavoritesMapApplet::shouldDrawNode(meshtastic_NodeInfoLite *node)
|
bool InkHUD::FavoritesMapApplet::shouldDrawNode(meshtastic_NodeInfoLite *node)
|
||||||
{
|
{
|
||||||
// Keep our own node available as map anchor/center; all others must be favorited.
|
// Keep our own node available as map anchor/center; all others must be favorited.
|
||||||
return node && (node->num == nodeDB->getNodeNum() || node->is_favorite);
|
return node && (node->num == nodeDB->getNodeNum() || nodeInfoLiteIsFavorite(node));
|
||||||
}
|
}
|
||||||
|
|
||||||
void InkHUD::FavoritesMapApplet::onRender(bool full)
|
void InkHUD::FavoritesMapApplet::onRender(bool full)
|
||||||
@@ -25,7 +25,7 @@ void InkHUD::FavoritesMapApplet::onRender(bool full)
|
|||||||
|
|
||||||
// Draw our latest "node of interest" as a special marker.
|
// Draw our latest "node of interest" as a special marker.
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(lastFrom);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(lastFrom);
|
||||||
if (node && node->is_favorite && nodeDB->hasValidPosition(node) && enoughMarkers())
|
if (node && nodeInfoLiteIsFavorite(node) && nodeDB->hasValidPosition(node) && enoughMarkers())
|
||||||
drawLabeledMarker(node);
|
drawLabeledMarker(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ ProcessMessage InkHUD::FavoritesMapApplet::handleReceived(const meshtastic_MeshP
|
|||||||
} else {
|
} else {
|
||||||
// For non-local packets, this applet only reacts to favorited nodes.
|
// For non-local packets, this applet only reacts to favorited nodes.
|
||||||
const meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(mp.from);
|
const meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(mp.from);
|
||||||
if (!sender || !sender->is_favorite)
|
if (!nodeInfoLiteIsFavorite(sender))
|
||||||
return ProcessMessage::CONTINUE;
|
return ProcessMessage::CONTINUE;
|
||||||
|
|
||||||
// Check if this position is from someone different than our previous position packet.
|
// Check if this position is from someone different than our previous position packet.
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ void InkHUD::HeardApplet::populateFromNodeDB()
|
|||||||
ordered.resize(maxCards());
|
ordered.resize(maxCards());
|
||||||
|
|
||||||
// Create card info for these (stale) node observations
|
// Create card info for these (stale) node observations
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
for (meshtastic_NodeInfoLite *node : ordered) {
|
for (const meshtastic_NodeInfoLite *node : ordered) {
|
||||||
CardInfo c;
|
CardInfo c;
|
||||||
c.nodeNum = node->num;
|
c.nodeNum = node->num;
|
||||||
|
|
||||||
@@ -89,14 +89,16 @@ void InkHUD::HeardApplet::populateFromNodeDB()
|
|||||||
c.hopsAway = node->hops_away;
|
c.hopsAway = node->hops_away;
|
||||||
|
|
||||||
if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) {
|
if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) {
|
||||||
// Get lat and long as float
|
meshtastic_PositionLite ourPos;
|
||||||
// Meshtastic stores these as integers internally
|
meshtastic_PositionLite theirPos;
|
||||||
float ourLat = ourNode->position.latitude_i * 1e-7;
|
if (nodeDB->copyNodePosition(ourNode->num, ourPos) && nodeDB->copyNodePosition(node->num, theirPos)) {
|
||||||
float ourLong = ourNode->position.longitude_i * 1e-7;
|
float ourLat = ourPos.latitude_i * 1e-7;
|
||||||
float theirLat = node->position.latitude_i * 1e-7;
|
float ourLong = ourPos.longitude_i * 1e-7;
|
||||||
float theirLong = node->position.longitude_i * 1e-7;
|
float theirLat = theirPos.latitude_i * 1e-7;
|
||||||
|
float theirLong = theirPos.longitude_i * 1e-7;
|
||||||
|
|
||||||
c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong);
|
c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert into the card collection (member of base class)
|
// Insert into the card collection (member of base class)
|
||||||
@@ -122,4 +124,4 @@ std::string InkHUD::HeardApplet::getHeaderText()
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -525,7 +525,7 @@ int InkHUD::Events::beforeReboot(void *unused)
|
|||||||
// Callback when a new text message is received
|
// Callback when a new text message is received
|
||||||
// Caches the most recently received message, for use by applets
|
// Caches the most recently received message, for use by applets
|
||||||
// Rx does not trigger a save to flash, however the data *will* be saved alongside other during shutdown, etc.
|
// Rx does not trigger a save to flash, however the data *will* be saved alongside other during shutdown, etc.
|
||||||
// Note: this is different from devicestate.rx_text_message, which may contain an *outgoing* message
|
// Note: this is intentionally separate from device-state message fields.
|
||||||
int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet)
|
int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet)
|
||||||
{
|
{
|
||||||
// Short circuit: don't store outgoing messages
|
// Short circuit: don't store outgoing messages
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ class Persistence
|
|||||||
|
|
||||||
// Most recently received text message
|
// Most recently received text message
|
||||||
// Value is updated by InkHUD::WindowManager, as a courtesy to applets
|
// Value is updated by InkHUD::WindowManager, as a courtesy to applets
|
||||||
// Note: different from devicestate.rx_text_message,
|
// InkHUD keeps its own latest-message cache for applets.
|
||||||
// which may contain an *outgoing message* to broadcast
|
|
||||||
struct LatestMessage {
|
struct LatestMessage {
|
||||||
MessageStore::Message broadcast; // Most recent message received broadcast
|
MessageStore::Message broadcast; // Most recent message received broadcast
|
||||||
MessageStore::Message dm; // Most recent received DM
|
MessageStore::Message dm; // Most recent received DM
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ Most recently received text message
|
|||||||
|
|
||||||
Collected here, so various user applets don't all have to store their own copy of this info.
|
Collected here, so various user applets don't all have to store their own copy of this info.
|
||||||
|
|
||||||
We are unable to use `devicestate.rx_text_message` for this purpose, because:
|
We keep this separate latest-message cache for this purpose, because:
|
||||||
|
|
||||||
- it is cleared by an outgoing text message
|
- it is cleared by an outgoing text message
|
||||||
- we want to store both a recent broadcast and a recent DM
|
- we want to store both a recent broadcast and a recent DM
|
||||||
|
|||||||
@@ -333,6 +333,12 @@ void InputBroker::Init()
|
|||||||
BaseType_t higherWake = 0;
|
BaseType_t higherWake = 0;
|
||||||
concurrency::mainDelay.interruptFromISR(&higherWake);
|
concurrency::mainDelay.interruptFromISR(&higherWake);
|
||||||
};
|
};
|
||||||
|
#if defined(ELECROW_ThinkNode_M7)
|
||||||
|
userConfigNoScreen.longLongPressTime = 15 * 1000;
|
||||||
|
userConfigNoScreen.longLongPress = INPUT_BROKER_FACTORY_RST;
|
||||||
|
#else
|
||||||
|
userConfigNoScreen.longLongPress = INPUT_BROKER_SHUTDOWN;
|
||||||
|
#endif
|
||||||
userConfigNoScreen.singlePress = INPUT_BROKER_USER_PRESS;
|
userConfigNoScreen.singlePress = INPUT_BROKER_USER_PRESS;
|
||||||
userConfigNoScreen.longPress = INPUT_BROKER_NONE;
|
userConfigNoScreen.longPress = INPUT_BROKER_NONE;
|
||||||
userConfigNoScreen.longPressTime = 500;
|
userConfigNoScreen.longPressTime = 500;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ enum input_broker_event {
|
|||||||
INPUT_BROKER_USER_PRESS,
|
INPUT_BROKER_USER_PRESS,
|
||||||
INPUT_BROKER_ALT_PRESS,
|
INPUT_BROKER_ALT_PRESS,
|
||||||
INPUT_BROKER_ALT_LONG,
|
INPUT_BROKER_ALT_LONG,
|
||||||
|
INPUT_BROKER_FACTORY_RST = 0x9a,
|
||||||
INPUT_BROKER_SHUTDOWN = 0x9b,
|
INPUT_BROKER_SHUTDOWN = 0x9b,
|
||||||
INPUT_BROKER_GPS_TOGGLE = 0x9e,
|
INPUT_BROKER_GPS_TOGGLE = 0x9e,
|
||||||
INPUT_BROKER_SEND_PING = 0xaf,
|
INPUT_BROKER_SEND_PING = 0xaf,
|
||||||
|
|||||||
+12
-5
@@ -59,12 +59,12 @@ NimbleBluetooth *nimbleBluetooth = nullptr;
|
|||||||
NRF52Bluetooth *nrf52Bluetooth = nullptr;
|
NRF52Bluetooth *nrf52Bluetooth = nullptr;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if HAS_WIFI || defined(USE_WS5500)
|
#if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
#include "mesh/api/WiFiServerAPI.h"
|
#include "mesh/api/WiFiServerAPI.h"
|
||||||
#include "mesh/wifi/WiFiAPClient.h"
|
#include "mesh/wifi/WiFiAPClient.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||||
#include "mesh/api/ethServerAPI.h"
|
#include "mesh/api/ethServerAPI.h"
|
||||||
#include "mesh/eth/ethClient.h"
|
#include "mesh/eth/ethClient.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -245,7 +245,7 @@ const char *getDeviceName()
|
|||||||
uint32_t timeLastPowered = 0;
|
uint32_t timeLastPowered = 0;
|
||||||
|
|
||||||
static OSThread *powerFSMthread;
|
static OSThread *powerFSMthread;
|
||||||
OSThread *ambientLightingThread;
|
AmbientLightingThread *ambientLightingThread;
|
||||||
|
|
||||||
RadioLibHal *RadioLibHAL = NULL;
|
RadioLibHal *RadioLibHAL = NULL;
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ void setup()
|
|||||||
|
|
||||||
#ifdef WIFI_LED
|
#ifdef WIFI_LED
|
||||||
pinMode(WIFI_LED, OUTPUT);
|
pinMode(WIFI_LED, OUTPUT);
|
||||||
digitalWrite(WIFI_LED, LOW);
|
digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef BLE_LED
|
#ifdef BLE_LED
|
||||||
@@ -731,8 +731,15 @@ void setup()
|
|||||||
#elif defined(USE_SH1107_128_64)
|
#elif defined(USE_SH1107_128_64)
|
||||||
screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // keep dimension of 128x64
|
screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // keep dimension of 128x64
|
||||||
#else
|
#else
|
||||||
if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO)
|
if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) {
|
||||||
screen_model = config.display.oled;
|
screen_model = config.display.oled;
|
||||||
|
|
||||||
|
// Fix: update geometry for SH1107 128x128 selected via menu
|
||||||
|
if (screen_model == meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128) {
|
||||||
|
screen_geometry = GEOMETRY_128_128;
|
||||||
|
screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // normalize
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, mes
|
|||||||
* @param bytes Buffer containing plaintext input.
|
* @param bytes Buffer containing plaintext input.
|
||||||
* @param bytesOut Output buffer to be populated with encrypted ciphertext.
|
* @param bytesOut Output buffer to be populated with encrypted ciphertext.
|
||||||
*/
|
*/
|
||||||
bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic,
|
bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic,
|
||||||
uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut)
|
uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut)
|
||||||
{
|
{
|
||||||
uint8_t *auth;
|
uint8_t *auth;
|
||||||
@@ -152,7 +152,7 @@ bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtas
|
|||||||
* @param bytes Buffer containing ciphertext input.
|
* @param bytes Buffer containing ciphertext input.
|
||||||
* @param bytesOut Output buffer to be populated with decrypted plaintext.
|
* @param bytesOut Output buffer to be populated with decrypted plaintext.
|
||||||
*/
|
*/
|
||||||
bool CryptoEngine::decryptCurve25519(uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic, uint64_t packetNum,
|
bool CryptoEngine::decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum,
|
||||||
size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut)
|
size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut)
|
||||||
{
|
{
|
||||||
const uint8_t *auth = bytes + numBytes - 12; // set to last 8 bytes of text?
|
const uint8_t *auth = bytes + numBytes - 12; // set to last 8 bytes of text?
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ class CryptoEngine
|
|||||||
|
|
||||||
#endif
|
#endif
|
||||||
void setDHPrivateKey(uint8_t *_private_key);
|
void setDHPrivateKey(uint8_t *_private_key);
|
||||||
virtual bool encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic,
|
// The remotePublic key parameter takes the public_key bytes container from
|
||||||
|
// a stored node header. NodeInfoLite is the on-device storage type since
|
||||||
|
// the slim refactor flattened UserLite into it.
|
||||||
|
virtual bool encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic,
|
||||||
uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut);
|
uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut);
|
||||||
virtual bool decryptCurve25519(uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic, uint64_t packetNum,
|
virtual bool decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum,
|
||||||
size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut);
|
size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut);
|
||||||
virtual bool setDHPublicKey(uint8_t *publicKey);
|
virtual bool setDHPublicKey(uint8_t *publicKey);
|
||||||
virtual void hash(uint8_t *bytes, size_t numBytes);
|
virtual void hash(uint8_t *bytes, size_t numBytes);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ template class LR20x0Interface<LR2021>;
|
|||||||
template class SX126xInterface<STM32WLx>;
|
template class SX126xInterface<STM32WLx>;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||||
#include "api/ethServerAPI.h"
|
#include "api/ethServerAPI.h"
|
||||||
template class ServerAPI<EthernetClient>;
|
template class ServerAPI<EthernetClient>;
|
||||||
template class APIServerPort<ethServerAPI, EthernetServer>;
|
template class APIServerPort<ethServerAPI, EthernetServer>;
|
||||||
|
|||||||
+12
-12
@@ -94,8 +94,9 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp)
|
|||||||
mp->decoded.portnum == meshtastic_PortNum_TELEMETRY_APP && mp->decoded.request_id > 0) {
|
mp->decoded.portnum == meshtastic_PortNum_TELEMETRY_APP && mp->decoded.request_id > 0) {
|
||||||
LOG_DEBUG("Received telemetry response. Skip sending our NodeInfo");
|
LOG_DEBUG("Received telemetry response. Skip sending our NodeInfo");
|
||||||
// ignore our request for its NodeInfo
|
// ignore our request for its NodeInfo
|
||||||
} else if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag && !nodeDB->getMeshNode(mp->from)->has_user &&
|
} else if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||||
nodeInfoModule && !isPreferredRebroadcaster && !nodeDB->isFull()) {
|
!nodeInfoLiteHasUser(nodeDB->getMeshNode(mp->from)) && nodeInfoModule && !isPreferredRebroadcaster &&
|
||||||
|
!nodeDB->isFull()) {
|
||||||
if (airTime->isTxAllowedChannelUtil(true)) {
|
if (airTime->isTxAllowedChannelUtil(true)) {
|
||||||
const int8_t hopsUsed = getHopsAway(*mp, config.lora.hop_limit);
|
const int8_t hopsUsed = getHopsAway(*mp, config.lora.hop_limit);
|
||||||
if (hopsUsed > (int32_t)(config.lora.hop_limit + 2)) {
|
if (hopsUsed > (int32_t)(config.lora.hop_limit + 2)) {
|
||||||
@@ -392,19 +393,16 @@ meshtastic_NodeInfoLite *MeshService::refreshLocalMeshNode()
|
|||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
assert(node);
|
assert(node);
|
||||||
|
|
||||||
// We might not have a position yet for our local node, in that case, at least try to send the time
|
|
||||||
if (!node->has_position) {
|
|
||||||
memset(&node->position, 0, sizeof(node->position));
|
|
||||||
node->has_position = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
meshtastic_PositionLite &position = node->position;
|
|
||||||
|
|
||||||
// Update our local node info with our time (even if we don't decide to update anyone else)
|
// Update our local node info with our time (even if we don't decide to update anyone else)
|
||||||
node->last_heard =
|
node->last_heard =
|
||||||
getValidTime(RTCQualityFromNet); // This nodedb timestamp might be stale, so update it if our clock is kinda valid
|
getValidTime(RTCQualityFromNet); // This nodedb timestamp might be stale, so update it if our clock is kinda valid
|
||||||
|
|
||||||
position.time = getValidTime(RTCQualityFromNet);
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
// Make sure our own NodeNum has a slot in the position map so subsequent
|
||||||
|
// updates (and the bundled NodeInfo emission to the phone) have somewhere
|
||||||
|
// to read from. Insert a default-zero entry on first call.
|
||||||
|
nodeDB->touchNodePositionTime(node->num, getValidTime(RTCQualityFromNet));
|
||||||
|
#endif
|
||||||
|
|
||||||
if (powerStatus->getHasBattery() == 1) {
|
if (powerStatus->getHasBattery() == 1) {
|
||||||
updateBatteryLevel(powerStatus->getBatteryChargePercent());
|
updateBatteryLevel(powerStatus->getBatteryChargePercent());
|
||||||
@@ -432,7 +430,9 @@ int MeshService::onGPSChanged(const meshtastic::GPSStatus *newStatus)
|
|||||||
// Used fixed position if configured regardless of GPS lock
|
// Used fixed position if configured regardless of GPS lock
|
||||||
if (config.position.fixed_position) {
|
if (config.position.fixed_position) {
|
||||||
LOG_WARN("Use fixed position");
|
LOG_WARN("Use fixed position");
|
||||||
pos = TypeConversions::ConvertToPosition(node->position);
|
meshtastic_PositionLite fixedSlot;
|
||||||
|
if (nodeDB->copyNodePosition(node->num, fixedSlot))
|
||||||
|
pos = TypeConversions::ConvertToPosition(fixedSlot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a fresh timestamp
|
// Add a fresh timestamp
|
||||||
|
|||||||
+659
-130
File diff suppressed because it is too large
Load Diff
+139
-4
@@ -4,12 +4,14 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
|
#include <map>
|
||||||
#include <pb_encode.h>
|
#include <pb_encode.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "MeshTypes.h"
|
#include "MeshTypes.h"
|
||||||
#include "NodeStatus.h"
|
#include "NodeStatus.h"
|
||||||
|
#include "concurrency/Lock.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include "mesh-pb-constants.h"
|
#include "mesh-pb-constants.h"
|
||||||
#include "mesh/generated/meshtastic/mesh.pb.h" // For CriticalErrorCode
|
#include "mesh/generated/meshtastic/mesh.pb.h" // For CriticalErrorCode
|
||||||
@@ -82,7 +84,9 @@ DeviceState versions used to be defined in the .proto file but really only this
|
|||||||
#define SEGMENT_CHANNELS 8
|
#define SEGMENT_CHANNELS 8
|
||||||
#define SEGMENT_NODEDATABASE 16
|
#define SEGMENT_NODEDATABASE 16
|
||||||
|
|
||||||
#define DEVICESTATE_CUR_VER 24
|
#define DEVICESTATE_CUR_VER 25
|
||||||
|
// Lowest on-disk version we still know how to load. v24 saves are migrated
|
||||||
|
// at boot via the parallel deviceonly_legacy descriptor and re-saved as v25.
|
||||||
#define DEVICESTATE_MIN_VER 24
|
#define DEVICESTATE_MIN_VER 24
|
||||||
|
|
||||||
extern meshtastic_DeviceState devicestate;
|
extern meshtastic_DeviceState devicestate;
|
||||||
@@ -166,6 +170,21 @@ class NodeDB
|
|||||||
Observable<const meshtastic::NodeStatus *> newStatus;
|
Observable<const meshtastic::NodeStatus *> newStatus;
|
||||||
pb_size_t numMeshNodes;
|
pb_size_t numMeshNodes;
|
||||||
|
|
||||||
|
// Satellite per-NodeNum maps. std::map avoids unordered_map's bucket-array
|
||||||
|
// preallocation; O(log N) lookup is fine at these sizes.
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
std::map<NodeNum, meshtastic_PositionLite> nodePositions;
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
std::map<NodeNum, meshtastic_DeviceMetrics> nodeTelemetry;
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
std::map<NodeNum, meshtastic_EnvironmentMetrics> nodeEnvironment;
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
std::map<NodeNum, meshtastic_StatusMessage> nodeStatus;
|
||||||
|
#endif
|
||||||
|
|
||||||
bool keyIsLowEntropy = false;
|
bool keyIsLowEntropy = false;
|
||||||
bool hasWarned = false;
|
bool hasWarned = false;
|
||||||
|
|
||||||
@@ -277,6 +296,43 @@ class NodeDB
|
|||||||
virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n);
|
virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n);
|
||||||
size_t getNumMeshNodes() { return numMeshNodes; }
|
size_t getNumMeshNodes() { return numMeshNodes; }
|
||||||
|
|
||||||
|
// Thread-safe satellite-map accessors. Return false if absent or the
|
||||||
|
// corresponding DB is compiled out.
|
||||||
|
bool copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const;
|
||||||
|
bool copyNodeTelemetry(NodeNum n, meshtastic_DeviceMetrics &out) const;
|
||||||
|
bool copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out) const;
|
||||||
|
bool copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const;
|
||||||
|
std::vector<NodeNum> snapshotPositionNodeNums(NodeNum exclude) const;
|
||||||
|
std::vector<NodeNum> snapshotTelemetryNodeNums(NodeNum exclude) const;
|
||||||
|
std::vector<NodeNum> snapshotEnvironmentNodeNums(NodeNum exclude) const;
|
||||||
|
std::vector<NodeNum> snapshotStatusNodeNums(NodeNum exclude) const;
|
||||||
|
|
||||||
|
void setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status);
|
||||||
|
void touchNodePositionTime(NodeNum n, uint32_t time);
|
||||||
|
|
||||||
|
bool hasNodePosition(NodeNum n) const
|
||||||
|
{
|
||||||
|
meshtastic_PositionLite scratch;
|
||||||
|
return copyNodePosition(n, scratch);
|
||||||
|
}
|
||||||
|
bool hasNodeTelemetry(NodeNum n) const
|
||||||
|
{
|
||||||
|
meshtastic_DeviceMetrics scratch;
|
||||||
|
return copyNodeTelemetry(n, scratch);
|
||||||
|
}
|
||||||
|
bool hasNodeEnvironment(NodeNum n) const
|
||||||
|
{
|
||||||
|
meshtastic_EnvironmentMetrics scratch;
|
||||||
|
return copyNodeEnvironment(n, scratch);
|
||||||
|
}
|
||||||
|
bool hasNodeStatus(NodeNum n) const
|
||||||
|
{
|
||||||
|
meshtastic_StatusMessage scratch;
|
||||||
|
return copyNodeStatus(n, scratch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void eraseNodeSatellites(NodeNum n);
|
||||||
|
|
||||||
UserLicenseStatus getLicenseStatus(uint32_t nodeNum);
|
UserLicenseStatus getLicenseStatus(uint32_t nodeNum);
|
||||||
|
|
||||||
size_t getMaxNodesAllocatedSize()
|
size_t getMaxNodesAllocatedSize()
|
||||||
@@ -285,7 +341,11 @@ class NodeDB
|
|||||||
emptyNodeDatabase.version = DEVICESTATE_CUR_VER;
|
emptyNodeDatabase.version = DEVICESTATE_CUR_VER;
|
||||||
size_t nodeDatabaseSize;
|
size_t nodeDatabaseSize;
|
||||||
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
|
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
|
||||||
return nodeDatabaseSize + (MAX_NUM_NODES * meshtastic_NodeInfoLite_size);
|
// Always include satellite slots so backups from higher-cap peers
|
||||||
|
// decode without truncation, even when our build excludes the DBs.
|
||||||
|
return nodeDatabaseSize + (MAX_NUM_NODES * meshtastic_NodeInfoLite_size) +
|
||||||
|
(MAX_NUM_NODES * meshtastic_NodePositionEntry_size) + (MAX_NUM_NODES * meshtastic_NodeTelemetryEntry_size) +
|
||||||
|
(MAX_NUM_NODES * meshtastic_NodeEnvironmentEntry_size) + (MAX_NUM_NODES * meshtastic_NodeStatusEntry_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns true if the maximum number of nodes is reached or we are running low on memory
|
// returns true if the maximum number of nodes is reached or we are running low on memory
|
||||||
@@ -329,6 +389,7 @@ class NodeDB
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
mutable concurrency::Lock satelliteMutex;
|
||||||
bool duplicateWarned = false;
|
bool duplicateWarned = false;
|
||||||
bool localPositionUpdatedSinceBoot = false;
|
bool localPositionUpdatedSinceBoot = false;
|
||||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||||
@@ -363,6 +424,16 @@ class NodeDB
|
|||||||
bool saveDeviceStateToDisk();
|
bool saveDeviceStateToDisk();
|
||||||
bool saveNodeDatabaseToDisk();
|
bool saveNodeDatabaseToDisk();
|
||||||
void sortMeshDB();
|
void sortMeshDB();
|
||||||
|
|
||||||
|
// Defined in NodeDBLegacyMigration.cpp. Decodes /prefs/nodes.proto via
|
||||||
|
// the legacy descriptor and copies entries into the v25 layout. Caller
|
||||||
|
// is responsible for save / install-default on the result.
|
||||||
|
bool migrateLegacyNodeDatabase();
|
||||||
|
|
||||||
|
// Route satellite-store decode entries straight into our maps instead of
|
||||||
|
// temp vectors. Must be paired — disarm before any other NodeDatabase decode.
|
||||||
|
void armNodeDatabaseDecodeTargets();
|
||||||
|
void disarmNodeDatabaseDecodeTargets();
|
||||||
};
|
};
|
||||||
|
|
||||||
extern NodeDB *nodeDB;
|
extern NodeDB *nodeDB;
|
||||||
@@ -397,10 +468,74 @@ extern meshtastic_CriticalErrorCode error_code;
|
|||||||
* A numeric error address (nonzero if available)
|
* A numeric error address (nonzero if available)
|
||||||
*/
|
*/
|
||||||
extern uint32_t error_address;
|
extern uint32_t error_address;
|
||||||
|
// Bit assignments for meshtastic_NodeInfoLite.bitfield.
|
||||||
#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT 0
|
#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT 0
|
||||||
#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK (1 << NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT)
|
#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK (1u << NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT)
|
||||||
#define NODEINFO_BITFIELD_IS_MUTED_SHIFT 1
|
#define NODEINFO_BITFIELD_IS_MUTED_SHIFT 1
|
||||||
#define NODEINFO_BITFIELD_IS_MUTED_MASK (1 << NODEINFO_BITFIELD_IS_MUTED_SHIFT)
|
#define NODEINFO_BITFIELD_IS_MUTED_MASK (1u << NODEINFO_BITFIELD_IS_MUTED_SHIFT)
|
||||||
|
#define NODEINFO_BITFIELD_VIA_MQTT_SHIFT 2
|
||||||
|
#define NODEINFO_BITFIELD_VIA_MQTT_MASK (1u << NODEINFO_BITFIELD_VIA_MQTT_SHIFT)
|
||||||
|
#define NODEINFO_BITFIELD_IS_FAVORITE_SHIFT 3
|
||||||
|
#define NODEINFO_BITFIELD_IS_FAVORITE_MASK (1u << NODEINFO_BITFIELD_IS_FAVORITE_SHIFT)
|
||||||
|
#define NODEINFO_BITFIELD_IS_IGNORED_SHIFT 4
|
||||||
|
#define NODEINFO_BITFIELD_IS_IGNORED_MASK (1u << NODEINFO_BITFIELD_IS_IGNORED_SHIFT)
|
||||||
|
#define NODEINFO_BITFIELD_HAS_USER_SHIFT 5
|
||||||
|
#define NODEINFO_BITFIELD_HAS_USER_MASK (1u << NODEINFO_BITFIELD_HAS_USER_SHIFT)
|
||||||
|
#define NODEINFO_BITFIELD_IS_LICENSED_SHIFT 6
|
||||||
|
#define NODEINFO_BITFIELD_IS_LICENSED_MASK (1u << NODEINFO_BITFIELD_IS_LICENSED_SHIFT)
|
||||||
|
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT 7
|
||||||
|
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT)
|
||||||
|
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8
|
||||||
|
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT)
|
||||||
|
// Bits 9..31 reserved for future single-bit flags.
|
||||||
|
|
||||||
|
// Convenience accessors so call sites read like the old struct fields.
|
||||||
|
inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteViaMqtt(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_VIA_MQTT_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteIsFavorite(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_FAVORITE_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteIsIgnored(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_IGNORED_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteIsLicensed(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_LICENSED_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteHasIsUnmessagable(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteIsUnmessagable(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteIsMuted(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK);
|
||||||
|
}
|
||||||
|
inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n)
|
||||||
|
{
|
||||||
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value)
|
||||||
|
{
|
||||||
|
if (!n)
|
||||||
|
return;
|
||||||
|
if (value)
|
||||||
|
n->bitfield |= mask;
|
||||||
|
else
|
||||||
|
n->bitfield &= ~mask;
|
||||||
|
}
|
||||||
|
|
||||||
#define Module_Config_size \
|
#define Module_Config_size \
|
||||||
(ModuleConfig_CannedMessageConfig_size + ModuleConfig_ExternalNotificationConfig_size + ModuleConfig_MQTTConfig_size + \
|
(ModuleConfig_CannedMessageConfig_size + ModuleConfig_ExternalNotificationConfig_size + ModuleConfig_MQTTConfig_size + \
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Migration from the legacy (pre-v25) NodeDatabase shape to the slim header +
|
||||||
|
// satellite maps layout. Lives in its own translation unit so the bulk
|
||||||
|
// fixed-shape decode + field-by-field copy doesn't clutter NodeDB.cpp.
|
||||||
|
//
|
||||||
|
// Caller (NodeDB::loadFromDisk) decides what to do with the result:
|
||||||
|
// - true -> persist via saveNodeDatabaseToDisk()
|
||||||
|
// - false -> reset via installDefaultNodeDatabase()
|
||||||
|
//
|
||||||
|
// This file (and the deviceonly_legacy proto) can be removed once
|
||||||
|
// DEVICESTATE_MIN_VER advances past 24.
|
||||||
|
|
||||||
|
#include "NodeDB.h"
|
||||||
|
#include "concurrency/LockGuard.h"
|
||||||
|
#include "configuration.h"
|
||||||
|
#include "mesh-pb-constants.h"
|
||||||
|
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <pb_decode.h>
|
||||||
|
#include <pb_encode.h>
|
||||||
|
|
||||||
|
bool meshtastic_NodeDatabase_Legacy_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field)
|
||||||
|
{
|
||||||
|
const auto *iter = reinterpret_cast<const pb_field_iter_t *>(field);
|
||||||
|
if (ostream) {
|
||||||
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeInfoLite_Legacy> *>(iter->pData);
|
||||||
|
for (auto item : *vec) {
|
||||||
|
if (!pb_encode_tag_for_field(ostream, iter))
|
||||||
|
return false;
|
||||||
|
if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_Legacy_fields, &item))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (istream) {
|
||||||
|
meshtastic_NodeInfoLite_Legacy node;
|
||||||
|
auto *vec = static_cast<std::vector<meshtastic_NodeInfoLite_Legacy> *>(iter->pData);
|
||||||
|
if (istream->bytes_left && pb_decode(istream, meshtastic_NodeInfoLite_Legacy_fields, &node))
|
||||||
|
vec->push_back(node);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NodeDB::migrateLegacyNodeDatabase()
|
||||||
|
{
|
||||||
|
LOG_WARN("NodeDatabase v%u: migrating to v%u", nodeDatabase.version, DEVICESTATE_CUR_VER);
|
||||||
|
|
||||||
|
// _init_zero brace-inits the embedded std::vector via its explicit
|
||||||
|
// (size_type, allocator) ctor, so default-construct instead.
|
||||||
|
meshtastic_NodeDatabase_Legacy legacyDb{};
|
||||||
|
legacyDb.version = 0;
|
||||||
|
meshtastic_NodeDatabase_Legacy legacyEmpty{};
|
||||||
|
legacyEmpty.version = DEVICESTATE_CUR_VER;
|
||||||
|
size_t legacyEmptyEncoded = 0;
|
||||||
|
pb_get_encoded_size(&legacyEmptyEncoded, meshtastic_NodeDatabase_Legacy_fields, &legacyEmpty);
|
||||||
|
const size_t legacyBufSize = legacyEmptyEncoded + (MAX_NUM_NODES * meshtastic_NodeInfoLite_Legacy_size);
|
||||||
|
auto legacyState = loadProto(nodeDatabaseFileName, legacyBufSize, sizeof(meshtastic_NodeDatabase_Legacy),
|
||||||
|
&meshtastic_NodeDatabase_Legacy_msg, &legacyDb);
|
||||||
|
if (legacyState != LoadFileResult::LOAD_SUCCESS) {
|
||||||
|
LOG_ERROR("Failed to load NodeDatabase via legacy descriptor; installing default");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeDatabase.nodes.clear();
|
||||||
|
const size_t maxToMigrate = std::min<size_t>(legacyDb.nodes.size(), MAX_NUM_NODES);
|
||||||
|
nodeDatabase.nodes.reserve(maxToMigrate);
|
||||||
|
size_t posCount = 0, telCount = 0;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
for (size_t i = 0; i < maxToMigrate; ++i) {
|
||||||
|
const auto &legacy = legacyDb.nodes[i];
|
||||||
|
meshtastic_NodeInfoLite slim = meshtastic_NodeInfoLite_init_default;
|
||||||
|
slim.num = legacy.num;
|
||||||
|
slim.snr = legacy.snr;
|
||||||
|
slim.last_heard = legacy.last_heard;
|
||||||
|
slim.channel = legacy.channel;
|
||||||
|
slim.has_hops_away = legacy.has_hops_away;
|
||||||
|
slim.hops_away = legacy.hops_away;
|
||||||
|
slim.next_hop = legacy.next_hop;
|
||||||
|
slim.bitfield = legacy.bitfield;
|
||||||
|
if (legacy.via_mqtt)
|
||||||
|
slim.bitfield |= NODEINFO_BITFIELD_VIA_MQTT_MASK;
|
||||||
|
if (legacy.is_favorite)
|
||||||
|
slim.bitfield |= NODEINFO_BITFIELD_IS_FAVORITE_MASK;
|
||||||
|
if (legacy.is_ignored)
|
||||||
|
slim.bitfield |= NODEINFO_BITFIELD_IS_IGNORED_MASK;
|
||||||
|
if (legacy.has_user) {
|
||||||
|
slim.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||||||
|
strncpy(slim.long_name, legacy.user.long_name, sizeof(slim.long_name));
|
||||||
|
slim.long_name[sizeof(slim.long_name) - 1] = '\0';
|
||||||
|
strncpy(slim.short_name, legacy.user.short_name, sizeof(slim.short_name));
|
||||||
|
slim.short_name[sizeof(slim.short_name) - 1] = '\0';
|
||||||
|
slim.hw_model = legacy.user.hw_model;
|
||||||
|
slim.role = legacy.user.role;
|
||||||
|
if (legacy.user.is_licensed)
|
||||||
|
slim.bitfield |= NODEINFO_BITFIELD_IS_LICENSED_MASK;
|
||||||
|
slim.public_key.size = legacy.user.public_key.size;
|
||||||
|
memcpy(slim.public_key.bytes, legacy.user.public_key.bytes, sizeof(slim.public_key.bytes));
|
||||||
|
if (legacy.user.has_is_unmessagable) {
|
||||||
|
slim.bitfield |= NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK;
|
||||||
|
if (legacy.user.is_unmessagable)
|
||||||
|
slim.bitfield |= NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK;
|
||||||
|
}
|
||||||
|
// macaddr deprecated since 1.2.11 - dropped from slim header.
|
||||||
|
}
|
||||||
|
nodeDatabase.nodes.push_back(slim);
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
if (legacy.has_position)
|
||||||
|
nodePositions[legacy.num] = legacy.position;
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
if (legacy.has_device_metrics)
|
||||||
|
nodeTelemetry[legacy.num] = legacy.device_metrics;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
posCount = nodePositions.size();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
telCount = nodeTelemetry.size();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
nodeDatabase.version = DEVICESTATE_CUR_VER;
|
||||||
|
meshNodes = &nodeDatabase.nodes;
|
||||||
|
numMeshNodes = nodeDatabase.nodes.size();
|
||||||
|
LOG_INFO("Migrated %u nodes from legacy -> v%u (positions: %u, telemetry: %u)", (unsigned)numMeshNodes, DEVICESTATE_CUR_VER,
|
||||||
|
(unsigned)posCount, (unsigned)telCount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
#include "Throttle.h"
|
#include "Throttle.h"
|
||||||
|
|
||||||
#define PACKETHISTORY_MAX \
|
#define PACKETHISTORY_MAX \
|
||||||
max((u_int32_t)(MAX_NUM_NODES * 2.0), \
|
max((uint32_t)(MAX_NUM_NODES * 2.0), \
|
||||||
(u_int32_t)100) // x2..3 Should suffice. Empirical setup. 16B per record malloc'ed, but no less than 100
|
(uint32_t)100) // x2..3 Should suffice. Empirical setup. 16B per record malloc'ed, but no less than 100
|
||||||
|
|
||||||
#define RECENT_WARN_AGE (10 * 60 * 1000L) // Warn if the packet that gets removed was more recent than 10 min
|
#define RECENT_WARN_AGE (10 * 60 * 1000L) // Warn if the packet that gets removed was more recent than 10 min
|
||||||
|
|
||||||
|
|||||||
+393
-12
@@ -81,6 +81,15 @@ void PhoneAPI::handleStartConfig()
|
|||||||
concurrency::LockGuard guard(&nodeInfoMutex);
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
nodeInfoForPhone = {};
|
nodeInfoForPhone = {};
|
||||||
nodeInfoQueue.clear();
|
nodeInfoQueue.clear();
|
||||||
|
replayQueue.clear();
|
||||||
|
replayPositionOrder.clear();
|
||||||
|
replayTelemetryOrder.clear();
|
||||||
|
replayEnvironmentOrder.clear();
|
||||||
|
replayStatusOrder.clear();
|
||||||
|
replayPositionIndex = 0;
|
||||||
|
replayTelemetryIndex = 0;
|
||||||
|
replayEnvironmentIndex = 0;
|
||||||
|
replayStatusIndex = 0;
|
||||||
}
|
}
|
||||||
resetReadIndex();
|
resetReadIndex();
|
||||||
}
|
}
|
||||||
@@ -120,6 +129,16 @@ void PhoneAPI::close()
|
|||||||
concurrency::LockGuard guard(&nodeInfoMutex);
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
nodeInfoForPhone = {};
|
nodeInfoForPhone = {};
|
||||||
nodeInfoQueue.clear();
|
nodeInfoQueue.clear();
|
||||||
|
replayQueue.clear();
|
||||||
|
replayPositionOrder.clear();
|
||||||
|
replayTelemetryOrder.clear();
|
||||||
|
replayEnvironmentOrder.clear();
|
||||||
|
replayStatusOrder.clear();
|
||||||
|
replayPositionIndex = 0;
|
||||||
|
replayTelemetryIndex = 0;
|
||||||
|
replayEnvironmentIndex = 0;
|
||||||
|
replayStatusIndex = 0;
|
||||||
|
replayPhase = REPLAY_PHASE_IDLE;
|
||||||
}
|
}
|
||||||
packetForPhone = NULL;
|
packetForPhone = NULL;
|
||||||
filesManifest.clear();
|
filesManifest.clear();
|
||||||
@@ -517,20 +536,17 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
|||||||
// Just in case we stored a different user.id in the past, but should never happen going forward
|
// Just in case we stored a different user.id in the past, but should never happen going forward
|
||||||
sprintf(infoToSend.user.id, "!%08x", infoToSend.num);
|
sprintf(infoToSend.user.id, "!%08x", infoToSend.num);
|
||||||
|
|
||||||
// Logging this really slows down sending nodes on initial connection because the serial console is so slow, so only
|
|
||||||
// uncomment if you really need to:
|
|
||||||
// LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard,
|
|
||||||
// nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name);
|
|
||||||
|
|
||||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag;
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag;
|
||||||
fromRadioScratch.node_info = infoToSend;
|
fromRadioScratch.node_info = infoToSend;
|
||||||
prefetchNodeInfos();
|
prefetchNodeInfos();
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG("Done sending %d of %d nodeinfos millis=%u", readIndex, nodeDB->getNumMeshNodes(), millis());
|
LOG_DEBUG("Done sending %d of %d nodeinfos millis=%u", readIndex, nodeDB->getNumMeshNodes(), millis());
|
||||||
concurrency::LockGuard guard(&nodeInfoMutex);
|
nodeInfoMutex.lock();
|
||||||
nodeInfoQueue.clear();
|
nodeInfoQueue.clear();
|
||||||
|
nodeInfoMutex.unlock();
|
||||||
|
// Satellite-DB replay (positions/telemetry/environment/status) now happens
|
||||||
|
// *after* config_complete_id, interleaved with live traffic in STATE_SEND_PACKETS.
|
||||||
state = STATE_SEND_FILEMANIFEST;
|
state = STATE_SEND_FILEMANIFEST;
|
||||||
// Go ahead and send that ID right now
|
|
||||||
return getFromRadio(buf);
|
return getFromRadio(buf);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -538,9 +554,8 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
|||||||
|
|
||||||
case STATE_SEND_FILEMANIFEST: {
|
case STATE_SEND_FILEMANIFEST: {
|
||||||
LOG_DEBUG("FromRadio=STATE_SEND_FILEMANIFEST");
|
LOG_DEBUG("FromRadio=STATE_SEND_FILEMANIFEST");
|
||||||
// last element
|
// ONLY_NODES variants skip the manifest.
|
||||||
if (config_state == filesManifest.size() ||
|
if (config_state == filesManifest.size() || config_nonce == SPECIAL_NONCE_ONLY_NODES) {
|
||||||
config_nonce == SPECIAL_NONCE_ONLY_NODES) { // also handles an empty filesManifest
|
|
||||||
config_state = 0;
|
config_state = 0;
|
||||||
filesManifest.clear();
|
filesManifest.clear();
|
||||||
// Skip to complete packet
|
// Skip to complete packet
|
||||||
@@ -585,6 +600,16 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
|||||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||||
fromRadioScratch.packet = *packetForPhone;
|
fromRadioScratch.packet = *packetForPhone;
|
||||||
releasePhonePacket();
|
releasePhonePacket();
|
||||||
|
} else if (replayPending()) {
|
||||||
|
// No live packet pending — feed the phone one cached satellite-DB packet.
|
||||||
|
// popReplayPacket advances through positions->telemetry->environment->status,
|
||||||
|
// and flips replayPhase back to IDLE when everything has been drained.
|
||||||
|
meshtastic_MeshPacket replayPkt;
|
||||||
|
if (popReplayPacket(replayPkt)) {
|
||||||
|
printPacket("replay packet to phone", &replayPkt);
|
||||||
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||||
|
fromRadioScratch.packet = replayPkt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -609,10 +634,19 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
|||||||
void PhoneAPI::sendConfigComplete()
|
void PhoneAPI::sendConfigComplete()
|
||||||
{
|
{
|
||||||
LOG_INFO("Config Send Complete millis=%u", millis());
|
LOG_INFO("Config Send Complete millis=%u", millis());
|
||||||
|
const bool shouldReplaySatellites = (config_nonce != SPECIAL_NONCE_ONLY_CONFIG);
|
||||||
|
// The phone sees config_complete_id first (treats sync as done), then the cached
|
||||||
|
// satellite-DB packets (positions / telemetry / environment / status) trickle in
|
||||||
|
// afterward as ordinary mesh packets (except SPECIAL_NONCE_ONLY_CONFIG, which
|
||||||
|
// skips node/satellite sync entirely). Any client that handles live POSITION_APP /
|
||||||
|
// TELEMETRY_APP / NODE_STATUS_APP packets handles these identically. STM32WL and
|
||||||
|
// other builds that compile the satellite DBs out produce no replay packets and
|
||||||
|
// the phase advances to IDLE in microseconds.
|
||||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_config_complete_id_tag;
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_config_complete_id_tag;
|
||||||
fromRadioScratch.config_complete_id = config_nonce;
|
fromRadioScratch.config_complete_id = config_nonce;
|
||||||
config_nonce = 0;
|
config_nonce = 0;
|
||||||
state = STATE_SEND_PACKETS;
|
state = STATE_SEND_PACKETS;
|
||||||
|
replayPhase = shouldReplaySatellites ? REPLAY_PHASE_POSITIONS : REPLAY_PHASE_IDLE;
|
||||||
if (api_type == TYPE_BLE) {
|
if (api_type == TYPE_BLE) {
|
||||||
service->api_state = service->STATE_BLE;
|
service->api_state = service->STATE_BLE;
|
||||||
} else if (api_type == TYPE_WIFI) {
|
} else if (api_type == TYPE_WIFI) {
|
||||||
@@ -653,6 +687,8 @@ void PhoneAPI::prefetchNodeInfos()
|
|||||||
{
|
{
|
||||||
bool added = false;
|
bool added = false;
|
||||||
bool wasEmpty = false;
|
bool wasEmpty = false;
|
||||||
|
// Other-node NodeInfos always go out thin (no bundled position/device_metrics).
|
||||||
|
// The post-config_complete_id replay drain delivers those as ordinary mesh packets.
|
||||||
// Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment.
|
// Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment.
|
||||||
{
|
{
|
||||||
concurrency::LockGuard guard(&nodeInfoMutex);
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
@@ -662,7 +698,7 @@ void PhoneAPI::prefetchNodeInfos()
|
|||||||
if (!nextNode)
|
if (!nextNode)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
auto info = TypeConversions::ConvertToNodeInfo(nextNode);
|
auto info = TypeConversions::ConvertToNodeInfoThin(nextNode);
|
||||||
bool isUs = info.num == nodeDB->getNodeNum();
|
bool isUs = info.num == nodeDB->getNodeNum();
|
||||||
info.hops_away = isUs ? 0 : info.hops_away;
|
info.hops_away = isUs ? 0 : info.hops_away;
|
||||||
info.last_heard = isUs ? getValidTime(RTCQualityFromNet) : info.last_heard;
|
info.last_heard = isUs ? getValidTime(RTCQualityFromNet) : info.last_heard;
|
||||||
@@ -682,6 +718,347 @@ void PhoneAPI::prefetchNodeInfos()
|
|||||||
onNowHasData(0);
|
onNowHasData(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos)
|
||||||
|
{
|
||||||
|
// Shape this exactly like a fresh live broadcast Position from the peer so the
|
||||||
|
// phone runs it through its normal "live position broadcast" handler path.
|
||||||
|
// to=ourNum would read as a DM-from-peer and never lands in node detail UI.
|
||||||
|
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default;
|
||||||
|
pkt.from = num;
|
||||||
|
pkt.to = NODENUM_BROADCAST;
|
||||||
|
pkt.id = generatePacketId();
|
||||||
|
pkt.rx_time = pos.time;
|
||||||
|
pkt.channel = 0;
|
||||||
|
pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
|
||||||
|
pkt.hop_start = pkt.hop_limit;
|
||||||
|
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||||
|
// Mark as if heard over the air, not internally generated
|
||||||
|
pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
|
||||||
|
pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||||
|
pkt.decoded.portnum = meshtastic_PortNum_POSITION_APP;
|
||||||
|
meshtastic_Position fullPos = TypeConversions::ConvertToPosition(pos);
|
||||||
|
size_t len =
|
||||||
|
pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_Position_msg, &fullPos);
|
||||||
|
pkt.decoded.payload.size = (pb_size_t)len;
|
||||||
|
return pkt;
|
||||||
|
}
|
||||||
|
|
||||||
|
meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const meshtastic_DeviceMetrics &metrics)
|
||||||
|
{
|
||||||
|
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default;
|
||||||
|
pkt.from = num;
|
||||||
|
pkt.to = NODENUM_BROADCAST;
|
||||||
|
pkt.id = generatePacketId();
|
||||||
|
// No native timestamp on telemetry packets here; use last_heard.
|
||||||
|
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
|
||||||
|
pkt.rx_time = header ? header->last_heard : 0;
|
||||||
|
pkt.channel = 0;
|
||||||
|
pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
|
||||||
|
pkt.hop_start = pkt.hop_limit;
|
||||||
|
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||||
|
// Mark as if heard over the air, not internally generated — iOS client filters
|
||||||
|
// TRANSPORT_INTERNAL packets out of broadcast peer state updates.
|
||||||
|
pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
|
||||||
|
pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||||
|
pkt.decoded.portnum = meshtastic_PortNum_TELEMETRY_APP;
|
||||||
|
meshtastic_Telemetry fullTel = meshtastic_Telemetry_init_default;
|
||||||
|
fullTel.time = pkt.rx_time;
|
||||||
|
fullTel.which_variant = meshtastic_Telemetry_device_metrics_tag;
|
||||||
|
fullTel.variant.device_metrics = metrics;
|
||||||
|
size_t len =
|
||||||
|
pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_Telemetry_msg, &fullTel);
|
||||||
|
pkt.decoded.payload.size = (pb_size_t)len;
|
||||||
|
return pkt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::beginReplayPositions()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
// Build excluded entirely - leave the order list empty so the phase
|
||||||
|
// immediately drains and advances.
|
||||||
|
replayPositionOrder.clear();
|
||||||
|
replayPositionIndex = 0;
|
||||||
|
#else
|
||||||
|
// Caller (popReplayPacket) only invokes us when replayPhase is armed.
|
||||||
|
// Snapshot the keyset at phase start so concurrent inserts/erases on the
|
||||||
|
// map don't invalidate iteration. Skip our own node - the phone already
|
||||||
|
// got our position bundled in STATE_SEND_OWN_NODEINFO.
|
||||||
|
replayPositionOrder = nodeDB->snapshotPositionNodeNums(nodeDB->getNodeNum());
|
||||||
|
replayPositionIndex = 0;
|
||||||
|
LOG_INFO("Begin position replay: %u entries millis=%u", (unsigned)replayPositionOrder.size(), millis());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::prefetchReplayPositions()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
bool added = false;
|
||||||
|
bool wasEmpty = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
wasEmpty = replayQueue.empty();
|
||||||
|
while (replayQueue.size() < kReplayPrefetchDepth && replayPositionIndex < replayPositionOrder.size()) {
|
||||||
|
NodeNum num = replayPositionOrder[replayPositionIndex++];
|
||||||
|
meshtastic_PositionLite pos;
|
||||||
|
if (!nodeDB->copyNodePosition(num, pos))
|
||||||
|
continue; // entry was evicted between snapshot and now
|
||||||
|
replayQueue.push_back(makeReplayPositionPacket(num, pos));
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added && wasEmpty)
|
||||||
|
onNowHasData(0);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::beginReplayTelemetry()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
replayTelemetryOrder.clear();
|
||||||
|
replayTelemetryIndex = 0;
|
||||||
|
#else
|
||||||
|
replayTelemetryOrder = nodeDB->snapshotTelemetryNodeNums(nodeDB->getNodeNum());
|
||||||
|
replayTelemetryIndex = 0;
|
||||||
|
LOG_INFO("Begin telemetry replay: %u entries millis=%u", (unsigned)replayTelemetryOrder.size(), millis());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::prefetchReplayTelemetry()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
bool added = false;
|
||||||
|
bool wasEmpty = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
wasEmpty = replayQueue.empty();
|
||||||
|
while (replayQueue.size() < kReplayPrefetchDepth && replayTelemetryIndex < replayTelemetryOrder.size()) {
|
||||||
|
NodeNum num = replayTelemetryOrder[replayTelemetryIndex++];
|
||||||
|
meshtastic_DeviceMetrics dm;
|
||||||
|
if (!nodeDB->copyNodeTelemetry(num, dm))
|
||||||
|
continue;
|
||||||
|
replayQueue.push_back(makeReplayTelemetryPacket(num, dm));
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added && wasEmpty)
|
||||||
|
onNowHasData(0);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env)
|
||||||
|
{
|
||||||
|
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default;
|
||||||
|
pkt.from = num;
|
||||||
|
pkt.to = NODENUM_BROADCAST;
|
||||||
|
pkt.id = generatePacketId();
|
||||||
|
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
|
||||||
|
pkt.rx_time = header ? header->last_heard : 0;
|
||||||
|
pkt.channel = 0;
|
||||||
|
pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
|
||||||
|
pkt.hop_start = pkt.hop_limit;
|
||||||
|
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||||
|
// Mark as if heard over the air, not internally generated — iOS client filters
|
||||||
|
// TRANSPORT_INTERNAL packets out of broadcast peer state updates.
|
||||||
|
pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
|
||||||
|
pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||||
|
pkt.decoded.portnum = meshtastic_PortNum_TELEMETRY_APP;
|
||||||
|
meshtastic_Telemetry fullTel = meshtastic_Telemetry_init_default;
|
||||||
|
fullTel.time = pkt.rx_time;
|
||||||
|
fullTel.which_variant = meshtastic_Telemetry_environment_metrics_tag;
|
||||||
|
fullTel.variant.environment_metrics = env;
|
||||||
|
size_t len =
|
||||||
|
pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_Telemetry_msg, &fullTel);
|
||||||
|
pkt.decoded.payload.size = (pb_size_t)len;
|
||||||
|
return pkt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::beginReplayEnvironment()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
replayEnvironmentOrder.clear();
|
||||||
|
replayEnvironmentIndex = 0;
|
||||||
|
#else
|
||||||
|
replayEnvironmentOrder = nodeDB->snapshotEnvironmentNodeNums(nodeDB->getNodeNum());
|
||||||
|
replayEnvironmentIndex = 0;
|
||||||
|
LOG_INFO("Begin environment replay: %u entries millis=%u", (unsigned)replayEnvironmentOrder.size(), millis());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::prefetchReplayEnvironment()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
bool added = false;
|
||||||
|
bool wasEmpty = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
wasEmpty = replayQueue.empty();
|
||||||
|
while (replayQueue.size() < kReplayPrefetchDepth && replayEnvironmentIndex < replayEnvironmentOrder.size()) {
|
||||||
|
NodeNum num = replayEnvironmentOrder[replayEnvironmentIndex++];
|
||||||
|
meshtastic_EnvironmentMetrics env;
|
||||||
|
if (!nodeDB->copyNodeEnvironment(num, env))
|
||||||
|
continue;
|
||||||
|
replayQueue.push_back(makeReplayEnvironmentPacket(num, env));
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added && wasEmpty)
|
||||||
|
onNowHasData(0);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status)
|
||||||
|
{
|
||||||
|
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default;
|
||||||
|
pkt.from = num;
|
||||||
|
pkt.to = NODENUM_BROADCAST;
|
||||||
|
pkt.id = generatePacketId();
|
||||||
|
// StatusMessage has no native timestamp; use last_heard.
|
||||||
|
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
|
||||||
|
pkt.rx_time = header ? header->last_heard : 0;
|
||||||
|
pkt.channel = 0;
|
||||||
|
pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
|
||||||
|
pkt.hop_start = pkt.hop_limit;
|
||||||
|
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||||
|
// Mark as if heard over the air, not internally generated — client filters
|
||||||
|
pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
|
||||||
|
pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||||
|
pkt.decoded.portnum = meshtastic_PortNum_NODE_STATUS_APP;
|
||||||
|
size_t len =
|
||||||
|
pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_StatusMessage_msg, &status);
|
||||||
|
pkt.decoded.payload.size = (pb_size_t)len;
|
||||||
|
return pkt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::beginReplayStatus()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
replayStatusOrder.clear();
|
||||||
|
replayStatusIndex = 0;
|
||||||
|
#else
|
||||||
|
replayStatusOrder = nodeDB->snapshotStatusNodeNums(nodeDB->getNodeNum());
|
||||||
|
replayStatusIndex = 0;
|
||||||
|
LOG_INFO("Begin status replay: %u entries millis=%u", (unsigned)replayStatusOrder.size(), millis());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::prefetchReplayStatus()
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
bool added = false;
|
||||||
|
bool wasEmpty = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
wasEmpty = replayQueue.empty();
|
||||||
|
while (replayQueue.size() < kReplayPrefetchDepth && replayStatusIndex < replayStatusOrder.size()) {
|
||||||
|
NodeNum num = replayStatusOrder[replayStatusIndex++];
|
||||||
|
meshtastic_StatusMessage status;
|
||||||
|
if (!nodeDB->copyNodeStatus(num, status) || status.status[0] == '\0')
|
||||||
|
continue;
|
||||||
|
replayQueue.push_back(makeReplayStatusPacket(num, status));
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added && wasEmpty)
|
||||||
|
onNowHasData(0);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop one cached satellite-DB packet from the active replay phase.
|
||||||
|
// Phases drain in order: positions -> telemetry -> environment -> status.
|
||||||
|
// When the current phase's cursor is exhausted (queue empty AND no more entries
|
||||||
|
// to snapshot), advance to the next phase. When all four phases are done,
|
||||||
|
// flip replayPhase back to IDLE and release the snapshot vectors.
|
||||||
|
//
|
||||||
|
// Returns true if a packet was placed in `out`; false if everything is drained.
|
||||||
|
bool PhoneAPI::popReplayPacket(meshtastic_MeshPacket &out)
|
||||||
|
{
|
||||||
|
while (replayPhase != REPLAY_PHASE_IDLE) {
|
||||||
|
// Prime the active phase: seed the snapshot vector on first entry,
|
||||||
|
// top up replayQueue from the snapshot up to kReplayPrefetchDepth.
|
||||||
|
switch (replayPhase) {
|
||||||
|
case REPLAY_PHASE_POSITIONS:
|
||||||
|
if (replayPositionOrder.empty() && replayPositionIndex == 0)
|
||||||
|
beginReplayPositions();
|
||||||
|
prefetchReplayPositions();
|
||||||
|
break;
|
||||||
|
case REPLAY_PHASE_TELEMETRY:
|
||||||
|
if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0)
|
||||||
|
beginReplayTelemetry();
|
||||||
|
prefetchReplayTelemetry();
|
||||||
|
break;
|
||||||
|
case REPLAY_PHASE_ENVIRONMENT:
|
||||||
|
if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0)
|
||||||
|
beginReplayEnvironment();
|
||||||
|
prefetchReplayEnvironment();
|
||||||
|
break;
|
||||||
|
case REPLAY_PHASE_STATUS:
|
||||||
|
if (replayStatusOrder.empty() && replayStatusIndex == 0)
|
||||||
|
beginReplayStatus();
|
||||||
|
prefetchReplayStatus();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
if (!replayQueue.empty()) {
|
||||||
|
out = replayQueue.front();
|
||||||
|
replayQueue.pop_front();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue empty AND no more entries to feed it — phase is exhausted.
|
||||||
|
advanceReplayPhase();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhoneAPI::advanceReplayPhase()
|
||||||
|
{
|
||||||
|
switch (replayPhase) {
|
||||||
|
case REPLAY_PHASE_POSITIONS:
|
||||||
|
LOG_DEBUG("Replay drain: positions done (count=%u) millis=%u", (unsigned)replayPositionIndex, millis());
|
||||||
|
replayPhase = REPLAY_PHASE_TELEMETRY;
|
||||||
|
break;
|
||||||
|
case REPLAY_PHASE_TELEMETRY:
|
||||||
|
LOG_DEBUG("Replay drain: telemetry done (count=%u) millis=%u", (unsigned)replayTelemetryIndex, millis());
|
||||||
|
replayPhase = REPLAY_PHASE_ENVIRONMENT;
|
||||||
|
break;
|
||||||
|
case REPLAY_PHASE_ENVIRONMENT:
|
||||||
|
LOG_DEBUG("Replay drain: environment done (count=%u) millis=%u", (unsigned)replayEnvironmentIndex, millis());
|
||||||
|
replayPhase = REPLAY_PHASE_STATUS;
|
||||||
|
break;
|
||||||
|
case REPLAY_PHASE_STATUS:
|
||||||
|
LOG_INFO("Replay drain complete (status count=%u) millis=%u", (unsigned)replayStatusIndex, millis());
|
||||||
|
replayPositionOrder.clear();
|
||||||
|
replayPositionOrder.shrink_to_fit();
|
||||||
|
replayTelemetryOrder.clear();
|
||||||
|
replayTelemetryOrder.shrink_to_fit();
|
||||||
|
replayEnvironmentOrder.clear();
|
||||||
|
replayEnvironmentOrder.shrink_to_fit();
|
||||||
|
replayStatusOrder.clear();
|
||||||
|
replayStatusOrder.shrink_to_fit();
|
||||||
|
replayPositionIndex = 0;
|
||||||
|
replayTelemetryIndex = 0;
|
||||||
|
replayEnvironmentIndex = 0;
|
||||||
|
replayStatusIndex = 0;
|
||||||
|
replayPhase = REPLAY_PHASE_IDLE;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void PhoneAPI::releaseMqttClientProxyPhonePacket()
|
void PhoneAPI::releaseMqttClientProxyPhonePacket()
|
||||||
{
|
{
|
||||||
if (mqttClientProxyMessageForPhone) {
|
if (mqttClientProxyMessageForPhone) {
|
||||||
@@ -759,7 +1136,11 @@ bool PhoneAPI::available()
|
|||||||
if (!packetForPhone)
|
if (!packetForPhone)
|
||||||
packetForPhone = service->getForPhone();
|
packetForPhone = service->getForPhone();
|
||||||
hasPacket = !!packetForPhone;
|
hasPacket = !!packetForPhone;
|
||||||
return hasPacket;
|
if (hasPacket)
|
||||||
|
return true;
|
||||||
|
// Trailing replay drain — feeds cached satellite-DB packets alongside
|
||||||
|
// (lower priority than) live traffic.
|
||||||
|
return replayPending();
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
LOG_ERROR("PhoneAPI::available unexpected state %d", state);
|
LOG_ERROR("PhoneAPI::available unexpected state %d", state);
|
||||||
|
|||||||
+51
-1
@@ -4,12 +4,16 @@
|
|||||||
#include "concurrency/Lock.h"
|
#include "concurrency/Lock.h"
|
||||||
#include "mesh-pb-constants.h"
|
#include "mesh-pb-constants.h"
|
||||||
#include "meshtastic/portnums.pb.h"
|
#include "meshtastic/portnums.pb.h"
|
||||||
|
#include <cstdint>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <iterator>
|
#include <iterator>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
// NodeNum stored as raw uint32_t below; including MeshTypes.h here breaks the
|
||||||
|
// LOG_WARN include order via Arduino.h/MemoryPool.h.
|
||||||
|
|
||||||
// Make sure that we never let our packets grow too large for one BLE packet
|
// Make sure that we never let our packets grow too large for one BLE packet
|
||||||
#define MAX_TO_FROM_RADIO_SIZE 512
|
#define MAX_TO_FROM_RADIO_SIZE 512
|
||||||
|
|
||||||
@@ -47,7 +51,20 @@ class PhoneAPI
|
|||||||
STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to to the client
|
STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to to the client
|
||||||
STATE_SEND_FILEMANIFEST, // Send file manifest
|
STATE_SEND_FILEMANIFEST, // Send file manifest
|
||||||
STATE_SEND_COMPLETE_ID,
|
STATE_SEND_COMPLETE_ID,
|
||||||
STATE_SEND_PACKETS // send packets or debug strings
|
STATE_SEND_PACKETS // live mesh packets + any cached satellite-DB replay that trails sync completion
|
||||||
|
};
|
||||||
|
|
||||||
|
// Satellite-DB replay (positions / telemetry / environment / status) used to live
|
||||||
|
// as four top-level states between STATE_SEND_OTHER_NODEINFOS and STATE_SEND_FILEMANIFEST.
|
||||||
|
// It now drains *after* config_complete_id has been emitted: the phone considers the
|
||||||
|
// initial sync done as soon as headers + manifest are delivered, and the cached
|
||||||
|
// position/telemetry/etc. trickle in alongside live mesh traffic inside STATE_SEND_PACKETS.
|
||||||
|
enum ReplayPhase : uint8_t {
|
||||||
|
REPLAY_PHASE_IDLE = 0, // not replaying (legacy clients, no-op DBs, or replay finished)
|
||||||
|
REPLAY_PHASE_POSITIONS,
|
||||||
|
REPLAY_PHASE_TELEMETRY,
|
||||||
|
REPLAY_PHASE_ENVIRONMENT,
|
||||||
|
REPLAY_PHASE_STATUS,
|
||||||
};
|
};
|
||||||
|
|
||||||
State state = STATE_SEND_NOTHING;
|
State state = STATE_SEND_NOTHING;
|
||||||
@@ -88,6 +105,21 @@ class PhoneAPI
|
|||||||
// Protect nodeInfoForPhone + nodeInfoQueue because NimBLE callbacks run in a separate FreeRTOS task.
|
// Protect nodeInfoForPhone + nodeInfoQueue because NimBLE callbacks run in a separate FreeRTOS task.
|
||||||
concurrency::Lock nodeInfoMutex;
|
concurrency::Lock nodeInfoMutex;
|
||||||
|
|
||||||
|
// Synthetic-packet replay queue (paced via prefetch).
|
||||||
|
std::deque<meshtastic_MeshPacket> replayQueue;
|
||||||
|
static constexpr size_t kReplayPrefetchDepth = 4;
|
||||||
|
// NodeNum snapshots taken at each replay phase start so concurrent
|
||||||
|
// satellite-map mutations don't invalidate iteration.
|
||||||
|
std::vector<uint32_t> replayPositionOrder;
|
||||||
|
std::vector<uint32_t> replayTelemetryOrder;
|
||||||
|
std::vector<uint32_t> replayEnvironmentOrder;
|
||||||
|
std::vector<uint32_t> replayStatusOrder;
|
||||||
|
size_t replayPositionIndex = 0;
|
||||||
|
size_t replayTelemetryIndex = 0;
|
||||||
|
size_t replayEnvironmentIndex = 0;
|
||||||
|
size_t replayStatusIndex = 0;
|
||||||
|
ReplayPhase replayPhase = REPLAY_PHASE_IDLE; // armed by sendConfigComplete() for full/default sync
|
||||||
|
|
||||||
meshtastic_ToRadio toRadioScratch = {
|
meshtastic_ToRadio toRadioScratch = {
|
||||||
0}; // this is a static scratch object, any data must be copied elsewhere before returning
|
0}; // this is a static scratch object, any data must be copied elsewhere before returning
|
||||||
|
|
||||||
@@ -185,6 +217,24 @@ class PhoneAPI
|
|||||||
void releaseQueueStatusPhonePacket();
|
void releaseQueueStatusPhonePacket();
|
||||||
|
|
||||||
void prefetchNodeInfos();
|
void prefetchNodeInfos();
|
||||||
|
void beginReplayPositions();
|
||||||
|
void prefetchReplayPositions();
|
||||||
|
void beginReplayTelemetry();
|
||||||
|
void prefetchReplayTelemetry();
|
||||||
|
void beginReplayEnvironment();
|
||||||
|
void prefetchReplayEnvironment();
|
||||||
|
void beginReplayStatus();
|
||||||
|
void prefetchReplayStatus();
|
||||||
|
meshtastic_MeshPacket makeReplayPositionPacket(uint32_t num, const meshtastic_PositionLite &pos);
|
||||||
|
meshtastic_MeshPacket makeReplayTelemetryPacket(uint32_t num, const meshtastic_DeviceMetrics &metrics);
|
||||||
|
meshtastic_MeshPacket makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env);
|
||||||
|
meshtastic_MeshPacket makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status);
|
||||||
|
|
||||||
|
// Post-sync replay drain: pop one cached packet from the active phase, advancing
|
||||||
|
// through positions -> telemetry -> environment -> status until everything is drained.
|
||||||
|
bool popReplayPacket(meshtastic_MeshPacket &out);
|
||||||
|
void advanceReplayPhase();
|
||||||
|
bool replayPending() const { return replayPhase != REPLAY_PHASE_IDLE; }
|
||||||
|
|
||||||
void releaseMqttClientProxyPhonePacket();
|
void releaseMqttClientProxyPhonePacket();
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ template <class T> class ProtobufModule : protected SinglePortModule
|
|||||||
const char *getSenderShortName(const meshtastic_MeshPacket &mp)
|
const char *getSenderShortName(const meshtastic_MeshPacket &mp)
|
||||||
{
|
{
|
||||||
auto node = nodeDB->getMeshNode(getFrom(&mp));
|
auto node = nodeDB->getMeshNode(getFrom(&mp));
|
||||||
const char *sender = (node) ? node->user.short_name : "???";
|
const char *sender = (node && nodeInfoLiteHasUser(node) && node->short_name[0]) ? node->short_name : "???";
|
||||||
return sender;
|
return sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,4 +121,4 @@ template <class T> class ProtobufModule : protected SinglePortModule
|
|||||||
return alterReceivedProtobuf(mp, decoded);
|
return alterReceivedProtobuf(mp, decoded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
|
|||||||
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
|
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
|
||||||
}
|
}
|
||||||
} else if (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag && p->channel == 0 &&
|
} else if (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag && p->channel == 0 &&
|
||||||
(nodeDB->getMeshNode(p->from) == nullptr || nodeDB->getMeshNode(p->from)->user.public_key.size == 0)) {
|
(nodeDB->getMeshNode(p->from) == nullptr || nodeDB->getMeshNode(p->from)->public_key.size == 0)) {
|
||||||
LOG_INFO("PKI packet from unknown node, send PKI_UNKNOWN_PUBKEY");
|
LOG_INFO("PKI packet from unknown node, send PKI_UNKNOWN_PUBKEY");
|
||||||
sendAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, getFrom(p), p->id, channels.getPrimaryIndex(),
|
sendAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, getFrom(p), p->id, channels.getPrimaryIndex(),
|
||||||
routingModule->getHopLimitForResponse(*p));
|
routingModule->getHopLimitForResponse(*p));
|
||||||
|
|||||||
+17
-17
@@ -120,17 +120,17 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
|
|||||||
if (!node)
|
if (!node)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Check 1: is_favorite (cheapest - single bool)
|
// Check 1: is_favorite (cheapest - single bit test)
|
||||||
if (!node->is_favorite)
|
if (!nodeInfoLiteIsFavorite(node))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Check 2: has_user (cheap - single bool)
|
// Check 2: has_user (cheap - single bit test)
|
||||||
if (!node->has_user)
|
if (!nodeInfoLiteHasUser(node))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Check 3: role check (moderate cost - multiple comparisons)
|
// Check 3: role check (moderate cost - multiple comparisons)
|
||||||
if (!IS_ONE_OF(node->user.role, meshtastic_Config_DeviceConfig_Role_ROUTER,
|
if (!IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
|
||||||
meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
|
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ ErrorCode Router::send(meshtastic_MeshPacket *p)
|
|||||||
cn->reply_id = p->id;
|
cn->reply_id = p->id;
|
||||||
cn->level = meshtastic_LogRecord_Level_WARNING;
|
cn->level = meshtastic_LogRecord_Level_WARNING;
|
||||||
cn->time = getValidTime(RTCQualityFromNet);
|
cn->time = getValidTime(RTCQualityFromNet);
|
||||||
sprintf(cn->message, "Duty cycle limit exceeded. You can send again in %d mins", silentMinutes);
|
snprintf(cn->message, sizeof(cn->message), "Duty cycle limit exceeded. You can send again in %d mins", silentMinutes);
|
||||||
service->sendClientNotification(cn);
|
service->sendClientNotification(cn);
|
||||||
|
|
||||||
meshtastic_Routing_Error err = meshtastic_Routing_Error_DUTY_CYCLE_LIMIT;
|
meshtastic_Routing_Error err = meshtastic_Routing_Error_DUTY_CYCLE_LIMIT;
|
||||||
@@ -433,7 +433,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
concurrency::LockGuard g(cryptLock);
|
concurrency::LockGuard g(cryptLock);
|
||||||
|
|
||||||
if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY &&
|
if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY &&
|
||||||
(nodeDB->getMeshNode(p->from) == NULL || !nodeDB->getMeshNode(p->from)->has_user)) {
|
!nodeInfoLiteHasUser(nodeDB->getMeshNode(p->from))) {
|
||||||
LOG_DEBUG("Node 0x%x not in nodeDB-> Rebroadcast mode KNOWN_ONLY will ignore packet", p->from);
|
LOG_DEBUG("Node 0x%x not in nodeDB-> Rebroadcast mode KNOWN_ONLY will ignore packet", p->from);
|
||||||
return DecodeState::DECODE_FAILURE;
|
return DecodeState::DECODE_FAILURE;
|
||||||
}
|
}
|
||||||
@@ -451,11 +451,11 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
// Attempt PKI decryption first
|
// Attempt PKI decryption first
|
||||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->getMeshNode(p->from) != nullptr &&
|
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->getMeshNode(p->from) != nullptr &&
|
||||||
nodeDB->getMeshNode(p->from)->user.public_key.size > 0 && nodeDB->getMeshNode(p->to)->user.public_key.size > 0 &&
|
nodeDB->getMeshNode(p->from)->public_key.size > 0 && nodeDB->getMeshNode(p->to) != nullptr &&
|
||||||
rawSize > MESHTASTIC_PKC_OVERHEAD) {
|
nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) {
|
||||||
LOG_DEBUG("Attempt PKI decryption");
|
LOG_DEBUG("Attempt PKI decryption");
|
||||||
|
|
||||||
if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->user.public_key, p->id, rawSize, p->encrypted.bytes,
|
if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->public_key, p->id, rawSize, p->encrypted.bytes,
|
||||||
bytes)) {
|
bytes)) {
|
||||||
LOG_INFO("PKI Decryption worked!");
|
LOG_INFO("PKI Decryption worked!");
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
decrypted = true;
|
decrypted = true;
|
||||||
LOG_INFO("Packet decrypted using PKI!");
|
LOG_INFO("Packet decrypted using PKI!");
|
||||||
p->pki_encrypted = true;
|
p->pki_encrypted = true;
|
||||||
memcpy(&p->public_key.bytes, nodeDB->getMeshNode(p->from)->user.public_key.bytes, 32);
|
memcpy(&p->public_key.bytes, nodeDB->getMeshNode(p->from)->public_key.bytes, 32);
|
||||||
p->public_key.size = 32;
|
p->public_key.size = 32;
|
||||||
p->decoded = decodedtmp;
|
p->decoded = decodedtmp;
|
||||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
|
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
|
||||||
@@ -665,18 +665,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
|||||||
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
||||||
return meshtastic_Routing_Error_TOO_LARGE;
|
return meshtastic_Routing_Error_TOO_LARGE;
|
||||||
// Check for a known public key for the destination
|
// Check for a known public key for the destination
|
||||||
if (node == nullptr || node->user.public_key.size != 32) {
|
if (node == nullptr || node->public_key.size != 32) {
|
||||||
LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to,
|
LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to,
|
||||||
p->decoded.portnum);
|
p->decoded.portnum);
|
||||||
return meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY;
|
return meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY;
|
||||||
}
|
}
|
||||||
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) &&
|
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) &&
|
||||||
memcmp(p->public_key.bytes, node->user.public_key.bytes, 32) != 0) {
|
memcmp(p->public_key.bytes, node->public_key.bytes, 32) != 0) {
|
||||||
LOG_WARN("Client public key differs from requested: 0x%02x, stored key begins 0x%02x", *p->public_key.bytes,
|
LOG_WARN("Client public key differs from requested: 0x%02x, stored key begins 0x%02x", *p->public_key.bytes,
|
||||||
*node->user.public_key.bytes);
|
*node->public_key.bytes);
|
||||||
return meshtastic_Routing_Error_PKI_FAILED;
|
return meshtastic_Routing_Error_PKI_FAILED;
|
||||||
}
|
}
|
||||||
crypto->encryptCurve25519(p->to, getFrom(p), node->user.public_key, p->id, numbytes, bytes, p->encrypted.bytes);
|
crypto->encryptCurve25519(p->to, getFrom(p), node->public_key, p->id, numbytes, bytes, p->encrypted.bytes);
|
||||||
numbytes += MESHTASTIC_PKC_OVERHEAD;
|
numbytes += MESHTASTIC_PKC_OVERHEAD;
|
||||||
p->channel = 0;
|
p->channel = 0;
|
||||||
p->pki_encrypted = true;
|
p->pki_encrypted = true;
|
||||||
@@ -860,7 +860,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
|||||||
}
|
}
|
||||||
|
|
||||||
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
|
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
|
||||||
if (node != NULL && node->is_ignored) {
|
if (nodeInfoLiteIsIgnored(node)) {
|
||||||
LOG_DEBUG("Ignore msg, 0x%x is ignored", p->from);
|
LOG_DEBUG("Ignore msg, 0x%x is ignored", p->from);
|
||||||
packetPool.release(p);
|
packetPool.release(p);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||||
#include "meshUtils.h"
|
#include "meshUtils.h"
|
||||||
|
|
||||||
meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite)
|
meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite,
|
||||||
|
const meshtastic_PositionLite *position,
|
||||||
|
const meshtastic_DeviceMetrics *deviceMetrics)
|
||||||
{
|
{
|
||||||
meshtastic_NodeInfo info = meshtastic_NodeInfo_init_default;
|
meshtastic_NodeInfo info = meshtastic_NodeInfo_init_default;
|
||||||
|
|
||||||
@@ -11,42 +13,57 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo
|
|||||||
info.snr = lite->snr;
|
info.snr = lite->snr;
|
||||||
info.last_heard = lite->last_heard;
|
info.last_heard = lite->last_heard;
|
||||||
info.channel = lite->channel;
|
info.channel = lite->channel;
|
||||||
info.via_mqtt = lite->via_mqtt;
|
info.via_mqtt = nodeInfoLiteViaMqtt(lite);
|
||||||
info.is_favorite = lite->is_favorite;
|
info.is_favorite = nodeInfoLiteIsFavorite(lite);
|
||||||
info.is_ignored = lite->is_ignored;
|
info.is_ignored = nodeInfoLiteIsIgnored(lite);
|
||||||
info.is_key_manually_verified = lite->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite);
|
||||||
info.is_muted = lite->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK;
|
info.is_muted = nodeInfoLiteIsMuted(lite);
|
||||||
|
|
||||||
if (lite->has_hops_away) {
|
if (lite->has_hops_away) {
|
||||||
info.has_hops_away = true;
|
info.has_hops_away = true;
|
||||||
info.hops_away = lite->hops_away;
|
info.hops_away = lite->hops_away;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lite->has_position) {
|
if (position) {
|
||||||
info.has_position = true;
|
info.has_position = true;
|
||||||
if (lite->position.latitude_i != 0)
|
if (position->latitude_i != 0)
|
||||||
info.position.has_latitude_i = true;
|
info.position.has_latitude_i = true;
|
||||||
info.position.latitude_i = lite->position.latitude_i;
|
info.position.latitude_i = position->latitude_i;
|
||||||
if (lite->position.longitude_i != 0)
|
if (position->longitude_i != 0)
|
||||||
info.position.has_longitude_i = true;
|
info.position.has_longitude_i = true;
|
||||||
info.position.longitude_i = lite->position.longitude_i;
|
info.position.longitude_i = position->longitude_i;
|
||||||
if (lite->position.altitude != 0)
|
if (position->altitude != 0)
|
||||||
info.position.has_altitude = true;
|
info.position.has_altitude = true;
|
||||||
info.position.altitude = lite->position.altitude;
|
info.position.altitude = position->altitude;
|
||||||
info.position.location_source = lite->position.location_source;
|
info.position.location_source = position->location_source;
|
||||||
info.position.time = lite->position.time;
|
info.position.time = position->time;
|
||||||
|
info.position.precision_bits = position->precision_bits;
|
||||||
}
|
}
|
||||||
if (lite->has_user) {
|
if (nodeInfoLiteHasUser(lite)) {
|
||||||
info.has_user = true;
|
info.has_user = true;
|
||||||
info.user = ConvertToUser(lite->num, lite->user);
|
info.user = ConvertToUser(lite);
|
||||||
}
|
}
|
||||||
if (lite->has_device_metrics) {
|
if (deviceMetrics) {
|
||||||
info.has_device_metrics = true;
|
info.has_device_metrics = true;
|
||||||
info.device_metrics = lite->device_metrics;
|
info.device_metrics = *deviceMetrics;
|
||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite)
|
||||||
|
{
|
||||||
|
meshtastic_PositionLite posScratch;
|
||||||
|
meshtastic_DeviceMetrics dmScratch;
|
||||||
|
const meshtastic_PositionLite *pos = (nodeDB && nodeDB->copyNodePosition(lite->num, posScratch)) ? &posScratch : nullptr;
|
||||||
|
const meshtastic_DeviceMetrics *dm = (nodeDB && nodeDB->copyNodeTelemetry(lite->num, dmScratch)) ? &dmScratch : nullptr;
|
||||||
|
return ConvertToNodeInfo(lite, pos, dm);
|
||||||
|
}
|
||||||
|
|
||||||
|
meshtastic_NodeInfo TypeConversions::ConvertToNodeInfoThin(const meshtastic_NodeInfoLite *lite)
|
||||||
|
{
|
||||||
|
return ConvertToNodeInfo(lite, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
meshtastic_PositionLite TypeConversions::ConvertToPositionLite(meshtastic_Position position)
|
meshtastic_PositionLite TypeConversions::ConvertToPositionLite(meshtastic_Position position)
|
||||||
{
|
{
|
||||||
meshtastic_PositionLite lite = meshtastic_PositionLite_init_default;
|
meshtastic_PositionLite lite = meshtastic_PositionLite_init_default;
|
||||||
@@ -55,6 +72,7 @@ meshtastic_PositionLite TypeConversions::ConvertToPositionLite(meshtastic_Positi
|
|||||||
lite.altitude = position.altitude;
|
lite.altitude = position.altitude;
|
||||||
lite.location_source = position.location_source;
|
lite.location_source = position.location_source;
|
||||||
lite.time = position.time;
|
lite.time = position.time;
|
||||||
|
lite.precision_bits = position.precision_bits;
|
||||||
|
|
||||||
return lite;
|
return lite;
|
||||||
}
|
}
|
||||||
@@ -73,50 +91,59 @@ meshtastic_Position TypeConversions::ConvertToPosition(meshtastic_PositionLite l
|
|||||||
position.altitude = lite.altitude;
|
position.altitude = lite.altitude;
|
||||||
position.location_source = lite.location_source;
|
position.location_source = lite.location_source;
|
||||||
position.time = lite.time;
|
position.time = lite.time;
|
||||||
|
// Preserve the peer's broadcast precision; falls back to 0 for entries cached
|
||||||
|
// before the precision_bits field existed in PositionLite (pre-migration data).
|
||||||
|
// iOS treats 0 as "unspecified precision" and won't render the pin — so for
|
||||||
|
// unset values, declare full precision so the stored lat/lon renders as a point.
|
||||||
|
position.precision_bits = lite.precision_bits == 0 ? 32 : lite.precision_bits;
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
meshtastic_UserLite TypeConversions::ConvertToUserLite(meshtastic_User user)
|
void TypeConversions::CopyUserToNodeInfoLite(meshtastic_NodeInfoLite *lite, const meshtastic_User &user)
|
||||||
{
|
{
|
||||||
meshtastic_UserLite lite = meshtastic_UserLite_init_default;
|
if (!lite)
|
||||||
|
return;
|
||||||
|
|
||||||
strncpy(lite.long_name, user.long_name, sizeof(lite.long_name));
|
strncpy(lite->long_name, user.long_name, sizeof(lite->long_name));
|
||||||
lite.long_name[sizeof(lite.long_name) - 1] = '\0';
|
lite->long_name[sizeof(lite->long_name) - 1] = '\0';
|
||||||
sanitizeUtf8(lite.long_name, sizeof(lite.long_name));
|
sanitizeUtf8(lite->long_name, sizeof(lite->long_name));
|
||||||
strncpy(lite.short_name, user.short_name, sizeof(lite.short_name));
|
strncpy(lite->short_name, user.short_name, sizeof(lite->short_name));
|
||||||
lite.short_name[sizeof(lite.short_name) - 1] = '\0';
|
lite->short_name[sizeof(lite->short_name) - 1] = '\0';
|
||||||
sanitizeUtf8(lite.short_name, sizeof(lite.short_name));
|
sanitizeUtf8(lite->short_name, sizeof(lite->short_name));
|
||||||
lite.hw_model = user.hw_model;
|
lite->hw_model = user.hw_model;
|
||||||
lite.role = user.role;
|
lite->role = user.role;
|
||||||
lite.is_licensed = user.is_licensed;
|
memcpy(lite->public_key.bytes, user.public_key.bytes, sizeof(lite->public_key.bytes));
|
||||||
memcpy(lite.macaddr, user.macaddr, sizeof(lite.macaddr));
|
lite->public_key.size = user.public_key.size;
|
||||||
memcpy(lite.public_key.bytes, user.public_key.bytes, sizeof(lite.public_key.bytes));
|
|
||||||
lite.public_key.size = user.public_key.size;
|
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_LICENSED_MASK, user.is_licensed);
|
||||||
lite.has_is_unmessagable = user.has_is_unmessagable;
|
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK, user.has_is_unmessagable);
|
||||||
lite.is_unmessagable = user.is_unmessagable;
|
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK, user.has_is_unmessagable && user.is_unmessagable);
|
||||||
return lite;
|
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
meshtastic_User TypeConversions::ConvertToUser(uint32_t nodeNum, meshtastic_UserLite lite)
|
meshtastic_User TypeConversions::ConvertToUser(const meshtastic_NodeInfoLite *lite)
|
||||||
{
|
{
|
||||||
meshtastic_User user = meshtastic_User_init_default;
|
meshtastic_User user = meshtastic_User_init_default;
|
||||||
|
if (!lite)
|
||||||
|
return user;
|
||||||
|
|
||||||
snprintf(user.id, sizeof(user.id), "!%08x", nodeNum);
|
snprintf(user.id, sizeof(user.id), "!%08x", lite->num);
|
||||||
strncpy(user.long_name, lite.long_name, sizeof(user.long_name));
|
strncpy(user.long_name, lite->long_name, sizeof(user.long_name));
|
||||||
user.long_name[sizeof(user.long_name) - 1] = '\0';
|
user.long_name[sizeof(user.long_name) - 1] = '\0';
|
||||||
sanitizeUtf8(user.long_name, sizeof(user.long_name));
|
sanitizeUtf8(user.long_name, sizeof(user.long_name));
|
||||||
strncpy(user.short_name, lite.short_name, sizeof(user.short_name));
|
strncpy(user.short_name, lite->short_name, sizeof(user.short_name));
|
||||||
user.short_name[sizeof(user.short_name) - 1] = '\0';
|
user.short_name[sizeof(user.short_name) - 1] = '\0';
|
||||||
sanitizeUtf8(user.short_name, sizeof(user.short_name));
|
sanitizeUtf8(user.short_name, sizeof(user.short_name));
|
||||||
user.hw_model = lite.hw_model;
|
user.hw_model = lite->hw_model;
|
||||||
user.role = lite.role;
|
user.role = lite->role;
|
||||||
user.is_licensed = lite.is_licensed;
|
user.is_licensed = nodeInfoLiteIsLicensed(lite);
|
||||||
memcpy(user.macaddr, lite.macaddr, sizeof(user.macaddr));
|
memcpy(user.public_key.bytes, lite->public_key.bytes, sizeof(user.public_key.bytes));
|
||||||
memcpy(user.public_key.bytes, lite.public_key.bytes, sizeof(user.public_key.bytes));
|
user.public_key.size = lite->public_key.size;
|
||||||
user.public_key.size = lite.public_key.size;
|
user.has_is_unmessagable = nodeInfoLiteHasIsUnmessagable(lite);
|
||||||
user.has_is_unmessagable = lite.has_is_unmessagable;
|
user.is_unmessagable = nodeInfoLiteIsUnmessagable(lite);
|
||||||
user.is_unmessagable = lite.is_unmessagable;
|
// macaddr is gone from the slim header; zero-fill for old clients that read it.
|
||||||
|
memset(user.macaddr, 0, sizeof(user.macaddr));
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,16 @@
|
|||||||
class TypeConversions
|
class TypeConversions
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
// Either pointer may be null; the corresponding has_* fields stay unset.
|
||||||
|
static meshtastic_NodeInfo ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite, const meshtastic_PositionLite *position,
|
||||||
|
const meshtastic_DeviceMetrics *deviceMetrics);
|
||||||
static meshtastic_NodeInfo ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite);
|
static meshtastic_NodeInfo ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite);
|
||||||
|
// Identity + link-state only; satellite payloads are replayed afterward.
|
||||||
|
static meshtastic_NodeInfo ConvertToNodeInfoThin(const meshtastic_NodeInfoLite *lite);
|
||||||
|
|
||||||
static meshtastic_PositionLite ConvertToPositionLite(meshtastic_Position position);
|
static meshtastic_PositionLite ConvertToPositionLite(meshtastic_Position position);
|
||||||
static meshtastic_Position ConvertToPosition(meshtastic_PositionLite lite);
|
static meshtastic_Position ConvertToPosition(meshtastic_PositionLite lite);
|
||||||
static meshtastic_UserLite ConvertToUserLite(meshtastic_User user);
|
|
||||||
static meshtastic_User ConvertToUser(uint32_t nodeNum, meshtastic_UserLite lite);
|
static void CopyUserToNodeInfoLite(meshtastic_NodeInfoLite *lite, const meshtastic_User &user);
|
||||||
|
static meshtastic_User ConvertToUser(const meshtastic_NodeInfoLite *lite);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||||
|
|
||||||
#include "ethServerAPI.h"
|
#include "ethServerAPI.h"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "ServerAPI.h"
|
#include "ServerAPI.h"
|
||||||
#ifndef USE_WS5500
|
#if !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||||
#include <RAK13800_W5100S.h>
|
#include <RAK13800_W5100S.h>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#include <RAK13800_W5100S.h>
|
#include <RAK13800_W5100S.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
|
|
||||||
#if HAS_NETWORKING
|
#if HAS_NETWORKING && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||||
|
|
||||||
#ifndef DISABLE_NTP
|
#ifndef DISABLE_NTP
|
||||||
#include <NTPClient.h>
|
#include <NTPClient.h>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ PB_BIND(meshtastic_AdminMessage_InputEvent, meshtastic_AdminMessage_InputEvent,
|
|||||||
PB_BIND(meshtastic_AdminMessage_OTAEvent, meshtastic_AdminMessage_OTAEvent, AUTO)
|
PB_BIND(meshtastic_AdminMessage_OTAEvent, meshtastic_AdminMessage_OTAEvent, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_LockdownAuth, meshtastic_LockdownAuth, AUTO)
|
||||||
|
|
||||||
|
|
||||||
PB_BIND(meshtastic_HamParameters, meshtastic_HamParameters, AUTO)
|
PB_BIND(meshtastic_HamParameters, meshtastic_HamParameters, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,41 @@ typedef struct _meshtastic_AdminMessage_OTAEvent {
|
|||||||
meshtastic_AdminMessage_OTAEvent_ota_hash_t ota_hash;
|
meshtastic_AdminMessage_OTAEvent_ota_hash_t ota_hash;
|
||||||
} meshtastic_AdminMessage_OTAEvent;
|
} meshtastic_AdminMessage_OTAEvent;
|
||||||
|
|
||||||
|
typedef PB_BYTES_ARRAY_T(32) meshtastic_LockdownAuth_passphrase_t;
|
||||||
|
/* Lockdown passphrase delivery payload.
|
||||||
|
|
||||||
|
One message handles three operations distinguished by content:
|
||||||
|
- Provision (first-time): passphrase set, lock_now=false. Firmware
|
||||||
|
generates DEK, wraps with passphrase-derived KEK, persists.
|
||||||
|
- Unlock: passphrase set, lock_now=false. Firmware verifies
|
||||||
|
passphrase against stored DEK, unlocks storage, authorizes the
|
||||||
|
connection that delivered this packet.
|
||||||
|
- Lock now: lock_now=true, passphrase ignored. Firmware revokes
|
||||||
|
all client auth and reboots into the locked state.
|
||||||
|
|
||||||
|
Firmware decides between provision and unlock based on its own state
|
||||||
|
(whether a DEK file already exists). Clients do not need to track
|
||||||
|
which case applies. */
|
||||||
|
typedef struct _meshtastic_LockdownAuth {
|
||||||
|
/* Passphrase bytes (1-32). Empty when lock_now is true.
|
||||||
|
Capped to 32 to match the proto cap on related security fields. */
|
||||||
|
meshtastic_LockdownAuth_passphrase_t passphrase;
|
||||||
|
/* Optional override of the boot-count token TTL granted on success.
|
||||||
|
0 = use firmware default (TOKEN_DEFAULT_BOOTS).
|
||||||
|
On reboot the firmware decrements this; when it reaches 0 the
|
||||||
|
device boots fully locked and requires a fresh passphrase. */
|
||||||
|
uint32_t boots_remaining;
|
||||||
|
/* Optional wall-clock expiry for the unlock token, as absolute
|
||||||
|
Unix-epoch seconds. 0 = no time limit (only the boot-count TTL
|
||||||
|
applies). On boot, if the device RTC is set and now > this value,
|
||||||
|
the token is treated as expired. */
|
||||||
|
uint32_t valid_until_epoch;
|
||||||
|
/* If true, ignore passphrase fields, immediately revoke all
|
||||||
|
connection-level admin authorization, and reboot the device into
|
||||||
|
the locked state. Always honoured regardless of current lock state. */
|
||||||
|
bool lock_now;
|
||||||
|
} meshtastic_LockdownAuth;
|
||||||
|
|
||||||
/* Parameters for setting up Meshtastic for ameteur radio usage */
|
/* Parameters for setting up Meshtastic for ameteur radio usage */
|
||||||
typedef struct _meshtastic_HamParameters {
|
typedef struct _meshtastic_HamParameters {
|
||||||
/* Amateur radio call sign, eg. KD2ABC */
|
/* Amateur radio call sign, eg. KD2ABC */
|
||||||
@@ -384,6 +419,15 @@ typedef struct _meshtastic_AdminMessage {
|
|||||||
meshtastic_AdminMessage_OTAEvent ota_request;
|
meshtastic_AdminMessage_OTAEvent ota_request;
|
||||||
/* Parameters and sensor configuration */
|
/* Parameters and sensor configuration */
|
||||||
meshtastic_SensorConfig sensor_config;
|
meshtastic_SensorConfig sensor_config;
|
||||||
|
/* Lockdown passphrase delivery / unlock / lock-now command for hardened
|
||||||
|
firmware builds (see MESHTASTIC_LOCKDOWN). Used to provision the
|
||||||
|
passphrase on first boot, unlock encrypted storage on subsequent
|
||||||
|
reboots, re-verify on already-unlocked devices to authorize a new
|
||||||
|
client connection, or immediately re-lock the device.
|
||||||
|
|
||||||
|
Replaces the earlier scheme that repurposed SecurityConfig.private_key
|
||||||
|
to carry passphrase bytes; that hack is retired. */
|
||||||
|
meshtastic_LockdownAuth lockdown_auth;
|
||||||
};
|
};
|
||||||
/* The node generates this key and sends it with any get_x_response packets.
|
/* The node generates this key and sends it with any get_x_response packets.
|
||||||
The client MUST include the same key with any set_x commands. Key expires after 300 seconds.
|
The client MUST include the same key with any set_x commands. Key expires after 300 seconds.
|
||||||
@@ -429,6 +473,7 @@ extern "C" {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#define meshtastic_KeyVerificationAdmin_message_type_ENUMTYPE meshtastic_KeyVerificationAdmin_MessageType
|
#define meshtastic_KeyVerificationAdmin_message_type_ENUMTYPE meshtastic_KeyVerificationAdmin_MessageType
|
||||||
|
|
||||||
|
|
||||||
@@ -441,6 +486,7 @@ extern "C" {
|
|||||||
#define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}}
|
#define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}}
|
||||||
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
|
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
|
||||||
#define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}}
|
#define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||||
|
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0}
|
||||||
#define meshtastic_HamParameters_init_default {"", 0, 0, ""}
|
#define meshtastic_HamParameters_init_default {"", 0, 0, ""}
|
||||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||||
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
|
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
|
||||||
@@ -453,6 +499,7 @@ extern "C" {
|
|||||||
#define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}}
|
#define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}}
|
||||||
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
|
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
|
||||||
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
|
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||||
|
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0}
|
||||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, ""}
|
#define meshtastic_HamParameters_init_zero {"", 0, 0, ""}
|
||||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||||
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
|
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
|
||||||
@@ -470,6 +517,10 @@ extern "C" {
|
|||||||
#define meshtastic_AdminMessage_InputEvent_touch_y_tag 4
|
#define meshtastic_AdminMessage_InputEvent_touch_y_tag 4
|
||||||
#define meshtastic_AdminMessage_OTAEvent_reboot_ota_mode_tag 1
|
#define meshtastic_AdminMessage_OTAEvent_reboot_ota_mode_tag 1
|
||||||
#define meshtastic_AdminMessage_OTAEvent_ota_hash_tag 2
|
#define meshtastic_AdminMessage_OTAEvent_ota_hash_tag 2
|
||||||
|
#define meshtastic_LockdownAuth_passphrase_tag 1
|
||||||
|
#define meshtastic_LockdownAuth_boots_remaining_tag 2
|
||||||
|
#define meshtastic_LockdownAuth_valid_until_epoch_tag 3
|
||||||
|
#define meshtastic_LockdownAuth_lock_now_tag 4
|
||||||
#define meshtastic_HamParameters_call_sign_tag 1
|
#define meshtastic_HamParameters_call_sign_tag 1
|
||||||
#define meshtastic_HamParameters_tx_power_tag 2
|
#define meshtastic_HamParameters_tx_power_tag 2
|
||||||
#define meshtastic_HamParameters_frequency_tag 3
|
#define meshtastic_HamParameters_frequency_tag 3
|
||||||
@@ -560,6 +611,7 @@ extern "C" {
|
|||||||
#define meshtastic_AdminMessage_nodedb_reset_tag 100
|
#define meshtastic_AdminMessage_nodedb_reset_tag 100
|
||||||
#define meshtastic_AdminMessage_ota_request_tag 102
|
#define meshtastic_AdminMessage_ota_request_tag 102
|
||||||
#define meshtastic_AdminMessage_sensor_config_tag 103
|
#define meshtastic_AdminMessage_sensor_config_tag 103
|
||||||
|
#define meshtastic_AdminMessage_lockdown_auth_tag 104
|
||||||
#define meshtastic_AdminMessage_session_passkey_tag 101
|
#define meshtastic_AdminMessage_session_passkey_tag 101
|
||||||
|
|
||||||
/* Struct field encoding specification for nanopb */
|
/* Struct field encoding specification for nanopb */
|
||||||
@@ -621,7 +673,8 @@ X(a, STATIC, ONEOF, INT32, (payload_variant,factory_reset_config,factory
|
|||||||
X(a, STATIC, ONEOF, BOOL, (payload_variant,nodedb_reset,nodedb_reset), 100) \
|
X(a, STATIC, ONEOF, BOOL, (payload_variant,nodedb_reset,nodedb_reset), 100) \
|
||||||
X(a, STATIC, SINGULAR, BYTES, session_passkey, 101) \
|
X(a, STATIC, SINGULAR, BYTES, session_passkey, 101) \
|
||||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,ota_request,ota_request), 102) \
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,ota_request,ota_request), 102) \
|
||||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103)
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103) \
|
||||||
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_auth,lockdown_auth), 104)
|
||||||
#define meshtastic_AdminMessage_CALLBACK NULL
|
#define meshtastic_AdminMessage_CALLBACK NULL
|
||||||
#define meshtastic_AdminMessage_DEFAULT NULL
|
#define meshtastic_AdminMessage_DEFAULT NULL
|
||||||
#define meshtastic_AdminMessage_payload_variant_get_channel_response_MSGTYPE meshtastic_Channel
|
#define meshtastic_AdminMessage_payload_variant_get_channel_response_MSGTYPE meshtastic_Channel
|
||||||
@@ -644,6 +697,7 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config)
|
|||||||
#define meshtastic_AdminMessage_payload_variant_key_verification_MSGTYPE meshtastic_KeyVerificationAdmin
|
#define meshtastic_AdminMessage_payload_variant_key_verification_MSGTYPE meshtastic_KeyVerificationAdmin
|
||||||
#define meshtastic_AdminMessage_payload_variant_ota_request_MSGTYPE meshtastic_AdminMessage_OTAEvent
|
#define meshtastic_AdminMessage_payload_variant_ota_request_MSGTYPE meshtastic_AdminMessage_OTAEvent
|
||||||
#define meshtastic_AdminMessage_payload_variant_sensor_config_MSGTYPE meshtastic_SensorConfig
|
#define meshtastic_AdminMessage_payload_variant_sensor_config_MSGTYPE meshtastic_SensorConfig
|
||||||
|
#define meshtastic_AdminMessage_payload_variant_lockdown_auth_MSGTYPE meshtastic_LockdownAuth
|
||||||
|
|
||||||
#define meshtastic_AdminMessage_InputEvent_FIELDLIST(X, a) \
|
#define meshtastic_AdminMessage_InputEvent_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, event_code, 1) \
|
X(a, STATIC, SINGULAR, UINT32, event_code, 1) \
|
||||||
@@ -659,6 +713,14 @@ X(a, STATIC, SINGULAR, BYTES, ota_hash, 2)
|
|||||||
#define meshtastic_AdminMessage_OTAEvent_CALLBACK NULL
|
#define meshtastic_AdminMessage_OTAEvent_CALLBACK NULL
|
||||||
#define meshtastic_AdminMessage_OTAEvent_DEFAULT NULL
|
#define meshtastic_AdminMessage_OTAEvent_DEFAULT NULL
|
||||||
|
|
||||||
|
#define meshtastic_LockdownAuth_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \
|
||||||
|
X(a, STATIC, SINGULAR, BOOL, lock_now, 4)
|
||||||
|
#define meshtastic_LockdownAuth_CALLBACK NULL
|
||||||
|
#define meshtastic_LockdownAuth_DEFAULT NULL
|
||||||
|
|
||||||
#define meshtastic_HamParameters_FIELDLIST(X, a) \
|
#define meshtastic_HamParameters_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, STRING, call_sign, 1) \
|
X(a, STATIC, SINGULAR, STRING, call_sign, 1) \
|
||||||
X(a, STATIC, SINGULAR, INT32, tx_power, 2) \
|
X(a, STATIC, SINGULAR, INT32, tx_power, 2) \
|
||||||
@@ -737,6 +799,7 @@ X(a, STATIC, OPTIONAL, UINT32, set_accuracy, 1)
|
|||||||
extern const pb_msgdesc_t meshtastic_AdminMessage_msg;
|
extern const pb_msgdesc_t meshtastic_AdminMessage_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg;
|
extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg;
|
extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg;
|
||||||
|
extern const pb_msgdesc_t meshtastic_LockdownAuth_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_HamParameters_msg;
|
extern const pb_msgdesc_t meshtastic_HamParameters_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_NodeRemoteHardwarePinsResponse_msg;
|
extern const pb_msgdesc_t meshtastic_NodeRemoteHardwarePinsResponse_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_SharedContact_msg;
|
extern const pb_msgdesc_t meshtastic_SharedContact_msg;
|
||||||
@@ -751,6 +814,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
|
|||||||
#define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg
|
#define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg
|
||||||
#define meshtastic_AdminMessage_InputEvent_fields &meshtastic_AdminMessage_InputEvent_msg
|
#define meshtastic_AdminMessage_InputEvent_fields &meshtastic_AdminMessage_InputEvent_msg
|
||||||
#define meshtastic_AdminMessage_OTAEvent_fields &meshtastic_AdminMessage_OTAEvent_msg
|
#define meshtastic_AdminMessage_OTAEvent_fields &meshtastic_AdminMessage_OTAEvent_msg
|
||||||
|
#define meshtastic_LockdownAuth_fields &meshtastic_LockdownAuth_msg
|
||||||
#define meshtastic_HamParameters_fields &meshtastic_HamParameters_msg
|
#define meshtastic_HamParameters_fields &meshtastic_HamParameters_msg
|
||||||
#define meshtastic_NodeRemoteHardwarePinsResponse_fields &meshtastic_NodeRemoteHardwarePinsResponse_msg
|
#define meshtastic_NodeRemoteHardwarePinsResponse_fields &meshtastic_NodeRemoteHardwarePinsResponse_msg
|
||||||
#define meshtastic_SharedContact_fields &meshtastic_SharedContact_msg
|
#define meshtastic_SharedContact_fields &meshtastic_SharedContact_msg
|
||||||
@@ -768,6 +832,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
|
|||||||
#define meshtastic_AdminMessage_size 511
|
#define meshtastic_AdminMessage_size 511
|
||||||
#define meshtastic_HamParameters_size 31
|
#define meshtastic_HamParameters_size 31
|
||||||
#define meshtastic_KeyVerificationAdmin_size 25
|
#define meshtastic_KeyVerificationAdmin_size 25
|
||||||
|
#define meshtastic_LockdownAuth_size 48
|
||||||
#define meshtastic_NodeRemoteHardwarePinsResponse_size 496
|
#define meshtastic_NodeRemoteHardwarePinsResponse_size 496
|
||||||
#define meshtastic_SCD30_config_size 27
|
#define meshtastic_SCD30_config_size 27
|
||||||
#define meshtastic_SCD4X_config_size 29
|
#define meshtastic_SCD4X_config_size 29
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ PB_BIND(meshtastic_NodeInfoLite, meshtastic_NodeInfoLite, AUTO)
|
|||||||
PB_BIND(meshtastic_DeviceState, meshtastic_DeviceState, 2)
|
PB_BIND(meshtastic_DeviceState, meshtastic_DeviceState, 2)
|
||||||
|
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO)
|
||||||
|
|
||||||
|
|
||||||
PB_BIND(meshtastic_NodeDatabase, meshtastic_NodeDatabase, AUTO)
|
PB_BIND(meshtastic_NodeDatabase, meshtastic_NodeDatabase, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ typedef struct _meshtastic_PositionLite {
|
|||||||
uint32_t time;
|
uint32_t time;
|
||||||
/* TODO: REPLACE */
|
/* TODO: REPLACE */
|
||||||
meshtastic_Position_LocSource location_source;
|
meshtastic_Position_LocSource location_source;
|
||||||
|
/* Indicates the bits of precision set by the sending node */
|
||||||
|
uint32_t precision_bits;
|
||||||
} meshtastic_PositionLite;
|
} meshtastic_PositionLite;
|
||||||
|
|
||||||
typedef PB_BYTES_ARRAY_T(32) meshtastic_UserLite_public_key_t;
|
typedef PB_BYTES_ARRAY_T(32) meshtastic_UserLite_public_key_t;
|
||||||
@@ -63,43 +65,35 @@ typedef struct _meshtastic_UserLite {
|
|||||||
bool is_unmessagable;
|
bool is_unmessagable;
|
||||||
} meshtastic_UserLite;
|
} meshtastic_UserLite;
|
||||||
|
|
||||||
|
typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t;
|
||||||
typedef struct _meshtastic_NodeInfoLite {
|
typedef struct _meshtastic_NodeInfoLite {
|
||||||
/* The node number */
|
/* The node number */
|
||||||
uint32_t num;
|
uint32_t num;
|
||||||
/* The user info for this node */
|
|
||||||
bool has_user;
|
|
||||||
meshtastic_UserLite user;
|
|
||||||
/* This position data. Note: before 1.2.14 we would also store the last time we've heard from this node in position.time, that is no longer true.
|
|
||||||
Position.time now indicates the last time we received a POSITION from that node. */
|
|
||||||
bool has_position;
|
|
||||||
meshtastic_PositionLite position;
|
|
||||||
/* Returns the Signal-to-noise ratio (SNR) of the last received message,
|
/* Returns the Signal-to-noise ratio (SNR) of the last received message,
|
||||||
as measured by the receiver. Return SNR of the last received message in dB */
|
as measured by the receiver. Return SNR of the last received message in dB */
|
||||||
float snr;
|
float snr;
|
||||||
/* Set to indicate the last time we received a packet from this node */
|
/* Set to indicate the last time we received a packet from this node */
|
||||||
uint32_t last_heard;
|
uint32_t last_heard;
|
||||||
/* The latest device metrics for the node. */
|
|
||||||
bool has_device_metrics;
|
|
||||||
meshtastic_DeviceMetrics device_metrics;
|
|
||||||
/* local channel index we heard that node on. Only populated if its not the default channel. */
|
/* local channel index we heard that node on. Only populated if its not the default channel. */
|
||||||
uint8_t channel;
|
uint8_t channel;
|
||||||
/* True if we witnessed the node over MQTT instead of LoRA transport */
|
|
||||||
bool via_mqtt;
|
|
||||||
/* Number of hops away from us this node is (0 if direct neighbor) */
|
/* Number of hops away from us this node is (0 if direct neighbor) */
|
||||||
bool has_hops_away;
|
bool has_hops_away;
|
||||||
uint8_t hops_away;
|
uint8_t hops_away;
|
||||||
/* True if node is in our favorites list
|
|
||||||
Persists between NodeDB internal clean ups */
|
|
||||||
bool is_favorite;
|
|
||||||
/* True if node is in our ignored list
|
|
||||||
Persists between NodeDB internal clean ups */
|
|
||||||
bool is_ignored;
|
|
||||||
/* Last byte of the node number of the node that should be used as the next hop to reach this node. */
|
/* Last byte of the node number of the node that should be used as the next hop to reach this node. */
|
||||||
uint8_t next_hop;
|
uint8_t next_hop;
|
||||||
/* Bitfield for storing booleans.
|
/* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */
|
||||||
LSB 0 is_key_manually_verified
|
|
||||||
LSB 1 is_muted */
|
|
||||||
uint32_t bitfield;
|
uint32_t bitfield;
|
||||||
|
/* A full name for this user, i.e. "Kevin Hester". */
|
||||||
|
char long_name[25];
|
||||||
|
/* A VERY short name, ideally two characters or an emoji.
|
||||||
|
Suitable for a tiny OLED screen. */
|
||||||
|
char short_name[5];
|
||||||
|
/* Hardware model the user's device is running. */
|
||||||
|
meshtastic_HardwareModel hw_model;
|
||||||
|
/* The user's role in the mesh. */
|
||||||
|
meshtastic_Config_DeviceConfig_Role role;
|
||||||
|
/* The public key of the user's device, for PKI-based encrypted DMs. */
|
||||||
|
meshtastic_NodeInfoLite_public_key_t public_key;
|
||||||
} meshtastic_NodeInfoLite;
|
} meshtastic_NodeInfoLite;
|
||||||
|
|
||||||
/* This message is never sent over the wire, but it is used for serializing DB
|
/* This message is never sent over the wire, but it is used for serializing DB
|
||||||
@@ -143,6 +137,30 @@ typedef struct _meshtastic_DeviceState {
|
|||||||
meshtastic_NodeRemoteHardwarePin node_remote_hardware_pins[12];
|
meshtastic_NodeRemoteHardwarePin node_remote_hardware_pins[12];
|
||||||
} meshtastic_DeviceState;
|
} meshtastic_DeviceState;
|
||||||
|
|
||||||
|
typedef struct _meshtastic_NodePositionEntry {
|
||||||
|
uint32_t num;
|
||||||
|
bool has_position;
|
||||||
|
meshtastic_PositionLite position;
|
||||||
|
} meshtastic_NodePositionEntry;
|
||||||
|
|
||||||
|
typedef struct _meshtastic_NodeTelemetryEntry {
|
||||||
|
uint32_t num;
|
||||||
|
bool has_device_metrics;
|
||||||
|
meshtastic_DeviceMetrics device_metrics;
|
||||||
|
} meshtastic_NodeTelemetryEntry;
|
||||||
|
|
||||||
|
typedef struct _meshtastic_NodeEnvironmentEntry {
|
||||||
|
uint32_t num;
|
||||||
|
bool has_environment_metrics;
|
||||||
|
meshtastic_EnvironmentMetrics environment_metrics;
|
||||||
|
} meshtastic_NodeEnvironmentEntry;
|
||||||
|
|
||||||
|
typedef struct _meshtastic_NodeStatusEntry {
|
||||||
|
uint32_t num;
|
||||||
|
bool has_status;
|
||||||
|
meshtastic_StatusMessage status;
|
||||||
|
} meshtastic_NodeStatusEntry;
|
||||||
|
|
||||||
typedef struct _meshtastic_NodeDatabase {
|
typedef struct _meshtastic_NodeDatabase {
|
||||||
/* A version integer used to invalidate old save files when we make
|
/* A version integer used to invalidate old save files when we make
|
||||||
incompatible changes This integer is set at build time and is private to
|
incompatible changes This integer is set at build time and is private to
|
||||||
@@ -150,6 +168,12 @@ typedef struct _meshtastic_NodeDatabase {
|
|||||||
uint32_t version;
|
uint32_t version;
|
||||||
/* New lite version of NodeDB to decrease memory footprint */
|
/* New lite version of NodeDB to decrease memory footprint */
|
||||||
std::vector<meshtastic_NodeInfoLite> nodes;
|
std::vector<meshtastic_NodeInfoLite> nodes;
|
||||||
|
/* Per-NodeNum satellite arrays. Constrained platforms (e.g. STM32WL) omit
|
||||||
|
these via MESHTASTIC_EXCLUDE_*DB build flags. */
|
||||||
|
std::vector<meshtastic_NodePositionEntry> positions;
|
||||||
|
std::vector<meshtastic_NodeTelemetryEntry> telemetry;
|
||||||
|
std::vector<meshtastic_NodeStatusEntry> status;
|
||||||
|
std::vector<meshtastic_NodeEnvironmentEntry> environment;
|
||||||
} meshtastic_NodeDatabase;
|
} meshtastic_NodeDatabase;
|
||||||
|
|
||||||
/* The on-disk saved channels */
|
/* The on-disk saved channels */
|
||||||
@@ -189,18 +213,26 @@ extern "C" {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/* Initializer values for message structs */
|
/* Initializer values for message structs */
|
||||||
#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN}
|
#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0}
|
||||||
#define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
#define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
||||||
#define meshtastic_NodeInfoLite_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0}
|
#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}}
|
||||||
#define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
#define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||||
#define meshtastic_NodeDatabase_init_default {0, {0}}
|
#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default}
|
||||||
|
#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default}
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_init_default {0, false, meshtastic_EnvironmentMetrics_init_default}
|
||||||
|
#define meshtastic_NodeStatusEntry_init_default {0, false, meshtastic_StatusMessage_init_default}
|
||||||
|
#define meshtastic_NodeDatabase_init_default {0, {0}, {0}, {0}, {0}, {0}}
|
||||||
#define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0}
|
#define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0}
|
||||||
#define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default}
|
#define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default}
|
||||||
#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN}
|
#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0}
|
||||||
#define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
#define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
||||||
#define meshtastic_NodeInfoLite_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0}
|
#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}}
|
||||||
#define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
#define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||||
#define meshtastic_NodeDatabase_init_zero {0, {0}}
|
#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero}
|
||||||
|
#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero}
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_init_zero {0, false, meshtastic_EnvironmentMetrics_init_zero}
|
||||||
|
#define meshtastic_NodeStatusEntry_init_zero {0, false, meshtastic_StatusMessage_init_zero}
|
||||||
|
#define meshtastic_NodeDatabase_init_zero {0, {0}, {0}, {0}, {0}, {0}}
|
||||||
#define meshtastic_ChannelFile_init_zero {0, {meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero}, 0}
|
#define meshtastic_ChannelFile_init_zero {0, {meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero}, 0}
|
||||||
#define meshtastic_BackupPreferences_init_zero {0, 0, false, meshtastic_LocalConfig_init_zero, false, meshtastic_LocalModuleConfig_init_zero, false, meshtastic_ChannelFile_init_zero, false, meshtastic_User_init_zero}
|
#define meshtastic_BackupPreferences_init_zero {0, 0, false, meshtastic_LocalConfig_init_zero, false, meshtastic_LocalModuleConfig_init_zero, false, meshtastic_ChannelFile_init_zero, false, meshtastic_User_init_zero}
|
||||||
|
|
||||||
@@ -210,6 +242,7 @@ extern "C" {
|
|||||||
#define meshtastic_PositionLite_altitude_tag 3
|
#define meshtastic_PositionLite_altitude_tag 3
|
||||||
#define meshtastic_PositionLite_time_tag 4
|
#define meshtastic_PositionLite_time_tag 4
|
||||||
#define meshtastic_PositionLite_location_source_tag 5
|
#define meshtastic_PositionLite_location_source_tag 5
|
||||||
|
#define meshtastic_PositionLite_precision_bits_tag 6
|
||||||
#define meshtastic_UserLite_macaddr_tag 1
|
#define meshtastic_UserLite_macaddr_tag 1
|
||||||
#define meshtastic_UserLite_long_name_tag 2
|
#define meshtastic_UserLite_long_name_tag 2
|
||||||
#define meshtastic_UserLite_short_name_tag 3
|
#define meshtastic_UserLite_short_name_tag 3
|
||||||
@@ -219,18 +252,17 @@ extern "C" {
|
|||||||
#define meshtastic_UserLite_public_key_tag 7
|
#define meshtastic_UserLite_public_key_tag 7
|
||||||
#define meshtastic_UserLite_is_unmessagable_tag 9
|
#define meshtastic_UserLite_is_unmessagable_tag 9
|
||||||
#define meshtastic_NodeInfoLite_num_tag 1
|
#define meshtastic_NodeInfoLite_num_tag 1
|
||||||
#define meshtastic_NodeInfoLite_user_tag 2
|
|
||||||
#define meshtastic_NodeInfoLite_position_tag 3
|
|
||||||
#define meshtastic_NodeInfoLite_snr_tag 4
|
#define meshtastic_NodeInfoLite_snr_tag 4
|
||||||
#define meshtastic_NodeInfoLite_last_heard_tag 5
|
#define meshtastic_NodeInfoLite_last_heard_tag 5
|
||||||
#define meshtastic_NodeInfoLite_device_metrics_tag 6
|
|
||||||
#define meshtastic_NodeInfoLite_channel_tag 7
|
#define meshtastic_NodeInfoLite_channel_tag 7
|
||||||
#define meshtastic_NodeInfoLite_via_mqtt_tag 8
|
|
||||||
#define meshtastic_NodeInfoLite_hops_away_tag 9
|
#define meshtastic_NodeInfoLite_hops_away_tag 9
|
||||||
#define meshtastic_NodeInfoLite_is_favorite_tag 10
|
|
||||||
#define meshtastic_NodeInfoLite_is_ignored_tag 11
|
|
||||||
#define meshtastic_NodeInfoLite_next_hop_tag 12
|
#define meshtastic_NodeInfoLite_next_hop_tag 12
|
||||||
#define meshtastic_NodeInfoLite_bitfield_tag 13
|
#define meshtastic_NodeInfoLite_bitfield_tag 13
|
||||||
|
#define meshtastic_NodeInfoLite_long_name_tag 14
|
||||||
|
#define meshtastic_NodeInfoLite_short_name_tag 15
|
||||||
|
#define meshtastic_NodeInfoLite_hw_model_tag 16
|
||||||
|
#define meshtastic_NodeInfoLite_role_tag 17
|
||||||
|
#define meshtastic_NodeInfoLite_public_key_tag 18
|
||||||
#define meshtastic_DeviceState_my_node_tag 2
|
#define meshtastic_DeviceState_my_node_tag 2
|
||||||
#define meshtastic_DeviceState_owner_tag 3
|
#define meshtastic_DeviceState_owner_tag 3
|
||||||
#define meshtastic_DeviceState_receive_queue_tag 5
|
#define meshtastic_DeviceState_receive_queue_tag 5
|
||||||
@@ -240,8 +272,20 @@ extern "C" {
|
|||||||
#define meshtastic_DeviceState_did_gps_reset_tag 11
|
#define meshtastic_DeviceState_did_gps_reset_tag 11
|
||||||
#define meshtastic_DeviceState_rx_waypoint_tag 12
|
#define meshtastic_DeviceState_rx_waypoint_tag 12
|
||||||
#define meshtastic_DeviceState_node_remote_hardware_pins_tag 13
|
#define meshtastic_DeviceState_node_remote_hardware_pins_tag 13
|
||||||
|
#define meshtastic_NodePositionEntry_num_tag 1
|
||||||
|
#define meshtastic_NodePositionEntry_position_tag 2
|
||||||
|
#define meshtastic_NodeTelemetryEntry_num_tag 1
|
||||||
|
#define meshtastic_NodeTelemetryEntry_device_metrics_tag 2
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_num_tag 1
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_environment_metrics_tag 2
|
||||||
|
#define meshtastic_NodeStatusEntry_num_tag 1
|
||||||
|
#define meshtastic_NodeStatusEntry_status_tag 2
|
||||||
#define meshtastic_NodeDatabase_version_tag 1
|
#define meshtastic_NodeDatabase_version_tag 1
|
||||||
#define meshtastic_NodeDatabase_nodes_tag 2
|
#define meshtastic_NodeDatabase_nodes_tag 2
|
||||||
|
#define meshtastic_NodeDatabase_positions_tag 3
|
||||||
|
#define meshtastic_NodeDatabase_telemetry_tag 4
|
||||||
|
#define meshtastic_NodeDatabase_status_tag 5
|
||||||
|
#define meshtastic_NodeDatabase_environment_tag 6
|
||||||
#define meshtastic_ChannelFile_channels_tag 1
|
#define meshtastic_ChannelFile_channels_tag 1
|
||||||
#define meshtastic_ChannelFile_version_tag 2
|
#define meshtastic_ChannelFile_version_tag 2
|
||||||
#define meshtastic_BackupPreferences_version_tag 1
|
#define meshtastic_BackupPreferences_version_tag 1
|
||||||
@@ -257,7 +301,8 @@ X(a, STATIC, SINGULAR, SFIXED32, latitude_i, 1) \
|
|||||||
X(a, STATIC, SINGULAR, SFIXED32, longitude_i, 2) \
|
X(a, STATIC, SINGULAR, SFIXED32, longitude_i, 2) \
|
||||||
X(a, STATIC, SINGULAR, INT32, altitude, 3) \
|
X(a, STATIC, SINGULAR, INT32, altitude, 3) \
|
||||||
X(a, STATIC, SINGULAR, FIXED32, time, 4) \
|
X(a, STATIC, SINGULAR, FIXED32, time, 4) \
|
||||||
X(a, STATIC, SINGULAR, UENUM, location_source, 5)
|
X(a, STATIC, SINGULAR, UENUM, location_source, 5) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, precision_bits, 6)
|
||||||
#define meshtastic_PositionLite_CALLBACK NULL
|
#define meshtastic_PositionLite_CALLBACK NULL
|
||||||
#define meshtastic_PositionLite_DEFAULT NULL
|
#define meshtastic_PositionLite_DEFAULT NULL
|
||||||
|
|
||||||
@@ -275,23 +320,19 @@ X(a, STATIC, OPTIONAL, BOOL, is_unmessagable, 9)
|
|||||||
|
|
||||||
#define meshtastic_NodeInfoLite_FIELDLIST(X, a) \
|
#define meshtastic_NodeInfoLite_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, num, 1) \
|
X(a, STATIC, SINGULAR, UINT32, num, 1) \
|
||||||
X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \
|
|
||||||
X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \
|
|
||||||
X(a, STATIC, SINGULAR, FLOAT, snr, 4) \
|
X(a, STATIC, SINGULAR, FLOAT, snr, 4) \
|
||||||
X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \
|
X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \
|
||||||
X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \
|
|
||||||
X(a, STATIC, SINGULAR, UINT32, channel, 7) \
|
X(a, STATIC, SINGULAR, UINT32, channel, 7) \
|
||||||
X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \
|
|
||||||
X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \
|
X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \
|
||||||
X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \
|
|
||||||
X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \
|
|
||||||
X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \
|
X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, bitfield, 13)
|
X(a, STATIC, SINGULAR, UINT32, bitfield, 13) \
|
||||||
|
X(a, STATIC, SINGULAR, STRING, long_name, 14) \
|
||||||
|
X(a, STATIC, SINGULAR, STRING, short_name, 15) \
|
||||||
|
X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \
|
||||||
|
X(a, STATIC, SINGULAR, UENUM, role, 17) \
|
||||||
|
X(a, STATIC, SINGULAR, BYTES, public_key, 18)
|
||||||
#define meshtastic_NodeInfoLite_CALLBACK NULL
|
#define meshtastic_NodeInfoLite_CALLBACK NULL
|
||||||
#define meshtastic_NodeInfoLite_DEFAULT NULL
|
#define meshtastic_NodeInfoLite_DEFAULT NULL
|
||||||
#define meshtastic_NodeInfoLite_user_MSGTYPE meshtastic_UserLite
|
|
||||||
#define meshtastic_NodeInfoLite_position_MSGTYPE meshtastic_PositionLite
|
|
||||||
#define meshtastic_NodeInfoLite_device_metrics_MSGTYPE meshtastic_DeviceMetrics
|
|
||||||
|
|
||||||
#define meshtastic_DeviceState_FIELDLIST(X, a) \
|
#define meshtastic_DeviceState_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, OPTIONAL, MESSAGE, my_node, 2) \
|
X(a, STATIC, OPTIONAL, MESSAGE, my_node, 2) \
|
||||||
@@ -312,13 +353,49 @@ X(a, STATIC, REPEATED, MESSAGE, node_remote_hardware_pins, 13)
|
|||||||
#define meshtastic_DeviceState_rx_waypoint_MSGTYPE meshtastic_MeshPacket
|
#define meshtastic_DeviceState_rx_waypoint_MSGTYPE meshtastic_MeshPacket
|
||||||
#define meshtastic_DeviceState_node_remote_hardware_pins_MSGTYPE meshtastic_NodeRemoteHardwarePin
|
#define meshtastic_DeviceState_node_remote_hardware_pins_MSGTYPE meshtastic_NodeRemoteHardwarePin
|
||||||
|
|
||||||
|
#define meshtastic_NodePositionEntry_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, num, 1) \
|
||||||
|
X(a, STATIC, OPTIONAL, MESSAGE, position, 2)
|
||||||
|
#define meshtastic_NodePositionEntry_CALLBACK NULL
|
||||||
|
#define meshtastic_NodePositionEntry_DEFAULT NULL
|
||||||
|
#define meshtastic_NodePositionEntry_position_MSGTYPE meshtastic_PositionLite
|
||||||
|
|
||||||
|
#define meshtastic_NodeTelemetryEntry_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, num, 1) \
|
||||||
|
X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 2)
|
||||||
|
#define meshtastic_NodeTelemetryEntry_CALLBACK NULL
|
||||||
|
#define meshtastic_NodeTelemetryEntry_DEFAULT NULL
|
||||||
|
#define meshtastic_NodeTelemetryEntry_device_metrics_MSGTYPE meshtastic_DeviceMetrics
|
||||||
|
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, num, 1) \
|
||||||
|
X(a, STATIC, OPTIONAL, MESSAGE, environment_metrics, 2)
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_CALLBACK NULL
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_DEFAULT NULL
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_environment_metrics_MSGTYPE meshtastic_EnvironmentMetrics
|
||||||
|
|
||||||
|
#define meshtastic_NodeStatusEntry_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, num, 1) \
|
||||||
|
X(a, STATIC, OPTIONAL, MESSAGE, status, 2)
|
||||||
|
#define meshtastic_NodeStatusEntry_CALLBACK NULL
|
||||||
|
#define meshtastic_NodeStatusEntry_DEFAULT NULL
|
||||||
|
#define meshtastic_NodeStatusEntry_status_MSGTYPE meshtastic_StatusMessage
|
||||||
|
|
||||||
#define meshtastic_NodeDatabase_FIELDLIST(X, a) \
|
#define meshtastic_NodeDatabase_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, version, 1) \
|
X(a, STATIC, SINGULAR, UINT32, version, 1) \
|
||||||
X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2)
|
X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) \
|
||||||
|
X(a, CALLBACK, REPEATED, MESSAGE, positions, 3) \
|
||||||
|
X(a, CALLBACK, REPEATED, MESSAGE, telemetry, 4) \
|
||||||
|
X(a, CALLBACK, REPEATED, MESSAGE, status, 5) \
|
||||||
|
X(a, CALLBACK, REPEATED, MESSAGE, environment, 6)
|
||||||
extern bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field);
|
extern bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field);
|
||||||
#define meshtastic_NodeDatabase_CALLBACK meshtastic_NodeDatabase_callback
|
#define meshtastic_NodeDatabase_CALLBACK meshtastic_NodeDatabase_callback
|
||||||
#define meshtastic_NodeDatabase_DEFAULT NULL
|
#define meshtastic_NodeDatabase_DEFAULT NULL
|
||||||
#define meshtastic_NodeDatabase_nodes_MSGTYPE meshtastic_NodeInfoLite
|
#define meshtastic_NodeDatabase_nodes_MSGTYPE meshtastic_NodeInfoLite
|
||||||
|
#define meshtastic_NodeDatabase_positions_MSGTYPE meshtastic_NodePositionEntry
|
||||||
|
#define meshtastic_NodeDatabase_telemetry_MSGTYPE meshtastic_NodeTelemetryEntry
|
||||||
|
#define meshtastic_NodeDatabase_status_MSGTYPE meshtastic_NodeStatusEntry
|
||||||
|
#define meshtastic_NodeDatabase_environment_MSGTYPE meshtastic_NodeEnvironmentEntry
|
||||||
|
|
||||||
#define meshtastic_ChannelFile_FIELDLIST(X, a) \
|
#define meshtastic_ChannelFile_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, REPEATED, MESSAGE, channels, 1) \
|
X(a, STATIC, REPEATED, MESSAGE, channels, 1) \
|
||||||
@@ -345,6 +422,10 @@ extern const pb_msgdesc_t meshtastic_PositionLite_msg;
|
|||||||
extern const pb_msgdesc_t meshtastic_UserLite_msg;
|
extern const pb_msgdesc_t meshtastic_UserLite_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_NodeInfoLite_msg;
|
extern const pb_msgdesc_t meshtastic_NodeInfoLite_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_DeviceState_msg;
|
extern const pb_msgdesc_t meshtastic_DeviceState_msg;
|
||||||
|
extern const pb_msgdesc_t meshtastic_NodePositionEntry_msg;
|
||||||
|
extern const pb_msgdesc_t meshtastic_NodeTelemetryEntry_msg;
|
||||||
|
extern const pb_msgdesc_t meshtastic_NodeEnvironmentEntry_msg;
|
||||||
|
extern const pb_msgdesc_t meshtastic_NodeStatusEntry_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_NodeDatabase_msg;
|
extern const pb_msgdesc_t meshtastic_NodeDatabase_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_ChannelFile_msg;
|
extern const pb_msgdesc_t meshtastic_ChannelFile_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
|
extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
|
||||||
@@ -354,6 +435,10 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
|
|||||||
#define meshtastic_UserLite_fields &meshtastic_UserLite_msg
|
#define meshtastic_UserLite_fields &meshtastic_UserLite_msg
|
||||||
#define meshtastic_NodeInfoLite_fields &meshtastic_NodeInfoLite_msg
|
#define meshtastic_NodeInfoLite_fields &meshtastic_NodeInfoLite_msg
|
||||||
#define meshtastic_DeviceState_fields &meshtastic_DeviceState_msg
|
#define meshtastic_DeviceState_fields &meshtastic_DeviceState_msg
|
||||||
|
#define meshtastic_NodePositionEntry_fields &meshtastic_NodePositionEntry_msg
|
||||||
|
#define meshtastic_NodeTelemetryEntry_fields &meshtastic_NodeTelemetryEntry_msg
|
||||||
|
#define meshtastic_NodeEnvironmentEntry_fields &meshtastic_NodeEnvironmentEntry_msg
|
||||||
|
#define meshtastic_NodeStatusEntry_fields &meshtastic_NodeStatusEntry_msg
|
||||||
#define meshtastic_NodeDatabase_fields &meshtastic_NodeDatabase_msg
|
#define meshtastic_NodeDatabase_fields &meshtastic_NodeDatabase_msg
|
||||||
#define meshtastic_ChannelFile_fields &meshtastic_ChannelFile_msg
|
#define meshtastic_ChannelFile_fields &meshtastic_ChannelFile_msg
|
||||||
#define meshtastic_BackupPreferences_fields &meshtastic_BackupPreferences_msg
|
#define meshtastic_BackupPreferences_fields &meshtastic_BackupPreferences_msg
|
||||||
@@ -364,8 +449,12 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
|
|||||||
#define meshtastic_BackupPreferences_size 2432
|
#define meshtastic_BackupPreferences_size 2432
|
||||||
#define meshtastic_ChannelFile_size 718
|
#define meshtastic_ChannelFile_size 718
|
||||||
#define meshtastic_DeviceState_size 1737
|
#define meshtastic_DeviceState_size 1737
|
||||||
#define meshtastic_NodeInfoLite_size 196
|
#define meshtastic_NodeEnvironmentEntry_size 170
|
||||||
#define meshtastic_PositionLite_size 28
|
#define meshtastic_NodeInfoLite_size 105
|
||||||
|
#define meshtastic_NodePositionEntry_size 42
|
||||||
|
#define meshtastic_NodeStatusEntry_size 89
|
||||||
|
#define meshtastic_NodeTelemetryEntry_size 35
|
||||||
|
#define meshtastic_PositionLite_size 34
|
||||||
#define meshtastic_UserLite_size 98
|
#define meshtastic_UserLite_size 98
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Automatically generated nanopb constant definitions */
|
||||||
|
/* Generated by nanopb-0.4.9.1 */
|
||||||
|
|
||||||
|
#include "meshtastic/deviceonly_legacy.pb.h"
|
||||||
|
#if PB_PROTO_HEADER_VERSION != 40
|
||||||
|
#error Regenerate this file with the current version of nanopb generator.
|
||||||
|
#endif
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_NodeInfoLite_Legacy, meshtastic_NodeInfoLite_Legacy, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_NodeDatabase_Legacy, meshtastic_NodeDatabase_Legacy, AUTO)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/* Automatically generated nanopb header */
|
||||||
|
/* Generated by nanopb-0.4.9.1 */
|
||||||
|
|
||||||
|
#ifndef PB_MESHTASTIC_MESHTASTIC_DEVICEONLY_LEGACY_PB_H_INCLUDED
|
||||||
|
#define PB_MESHTASTIC_MESHTASTIC_DEVICEONLY_LEGACY_PB_H_INCLUDED
|
||||||
|
#include <pb.h>
|
||||||
|
#include <vector>
|
||||||
|
#include "meshtastic/deviceonly.pb.h"
|
||||||
|
#include "meshtastic/telemetry.pb.h"
|
||||||
|
|
||||||
|
#if PB_PROTO_HEADER_VERSION != 40
|
||||||
|
#error Regenerate this file with the current version of nanopb generator.
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Struct definitions */
|
||||||
|
/* Legacy NodeInfoLite descriptor used only to decode pre-split
|
||||||
|
/prefs/nodes.proto saves during the v24 -> v25 migration boot.
|
||||||
|
This preserves the original NodeInfoLite-compatible field numbers needed
|
||||||
|
to parse old wire bytes cleanly, including user (2), position (3),
|
||||||
|
device_metrics (6), and the legacy-only compatibility fields via_mqtt (8),
|
||||||
|
is_favorite (10), and is_ignored (11). Steady-state code does not use
|
||||||
|
this struct; it is dropped after migration completes. This file should be
|
||||||
|
removed once DEVICESTATE_MIN_VER advances past 24. */
|
||||||
|
typedef struct _meshtastic_NodeInfoLite_Legacy {
|
||||||
|
uint32_t num;
|
||||||
|
bool has_user;
|
||||||
|
meshtastic_UserLite user;
|
||||||
|
bool has_position;
|
||||||
|
meshtastic_PositionLite position;
|
||||||
|
float snr;
|
||||||
|
uint32_t last_heard;
|
||||||
|
bool has_device_metrics;
|
||||||
|
meshtastic_DeviceMetrics device_metrics;
|
||||||
|
uint8_t channel;
|
||||||
|
bool via_mqtt;
|
||||||
|
bool has_hops_away;
|
||||||
|
uint8_t hops_away;
|
||||||
|
bool is_favorite;
|
||||||
|
bool is_ignored;
|
||||||
|
uint8_t next_hop;
|
||||||
|
uint32_t bitfield;
|
||||||
|
} meshtastic_NodeInfoLite_Legacy;
|
||||||
|
|
||||||
|
/* Legacy NodeDatabase shape: one repeated array of fat NodeInfoLite_Legacy
|
||||||
|
with no satellite position/telemetry arrays. */
|
||||||
|
typedef struct _meshtastic_NodeDatabase_Legacy {
|
||||||
|
uint32_t version;
|
||||||
|
std::vector<meshtastic_NodeInfoLite_Legacy> nodes;
|
||||||
|
} meshtastic_NodeDatabase_Legacy;
|
||||||
|
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Initializer values for message structs */
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0}
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_init_default {0, {0}}
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0}
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_init_zero {0, {0}}
|
||||||
|
|
||||||
|
/* Field tags (for use in manual encoding/decoding) */
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_num_tag 1
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_user_tag 2
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_position_tag 3
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_snr_tag 4
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_last_heard_tag 5
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_device_metrics_tag 6
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_channel_tag 7
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_via_mqtt_tag 8
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_hops_away_tag 9
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_is_favorite_tag 10
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_is_ignored_tag 11
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_next_hop_tag 12
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_bitfield_tag 13
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_version_tag 1
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_nodes_tag 2
|
||||||
|
|
||||||
|
/* Struct field encoding specification for nanopb */
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, num, 1) \
|
||||||
|
X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \
|
||||||
|
X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \
|
||||||
|
X(a, STATIC, SINGULAR, FLOAT, snr, 4) \
|
||||||
|
X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \
|
||||||
|
X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, channel, 7) \
|
||||||
|
X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \
|
||||||
|
X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \
|
||||||
|
X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \
|
||||||
|
X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, bitfield, 13)
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_CALLBACK NULL
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_DEFAULT NULL
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_user_MSGTYPE meshtastic_UserLite
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_position_MSGTYPE meshtastic_PositionLite
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_device_metrics_MSGTYPE meshtastic_DeviceMetrics
|
||||||
|
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, version, 1) \
|
||||||
|
X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2)
|
||||||
|
extern bool meshtastic_NodeDatabase_Legacy_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field);
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_CALLBACK meshtastic_NodeDatabase_Legacy_callback
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_DEFAULT NULL
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_nodes_MSGTYPE meshtastic_NodeInfoLite_Legacy
|
||||||
|
|
||||||
|
extern const pb_msgdesc_t meshtastic_NodeInfoLite_Legacy_msg;
|
||||||
|
extern const pb_msgdesc_t meshtastic_NodeDatabase_Legacy_msg;
|
||||||
|
|
||||||
|
/* Defines for backwards compatibility with code written before nanopb-0.4.0 */
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_fields &meshtastic_NodeInfoLite_Legacy_msg
|
||||||
|
#define meshtastic_NodeDatabase_Legacy_fields &meshtastic_NodeDatabase_Legacy_msg
|
||||||
|
|
||||||
|
/* Maximum encoded size of messages (where known) */
|
||||||
|
/* meshtastic_NodeDatabase_Legacy_size depends on runtime parameters */
|
||||||
|
#define MESHTASTIC_MESHTASTIC_DEVICEONLY_LEGACY_PB_H_MAX_SIZE meshtastic_NodeInfoLite_Legacy_size
|
||||||
|
#define meshtastic_NodeInfoLite_Legacy_size 202
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -57,6 +57,9 @@ PB_BIND(meshtastic_QueueStatus, meshtastic_QueueStatus, AUTO)
|
|||||||
PB_BIND(meshtastic_FromRadio, meshtastic_FromRadio, 2)
|
PB_BIND(meshtastic_FromRadio, meshtastic_FromRadio, 2)
|
||||||
|
|
||||||
|
|
||||||
|
PB_BIND(meshtastic_LockdownStatus, meshtastic_LockdownStatus, AUTO)
|
||||||
|
|
||||||
|
|
||||||
PB_BIND(meshtastic_ClientNotification, meshtastic_ClientNotification, 2)
|
PB_BIND(meshtastic_ClientNotification, meshtastic_ClientNotification, 2)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +137,8 @@ PB_BIND(meshtastic_ChunkedPayloadResponse, meshtastic_ChunkedPayloadResponse, AU
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ typedef enum _meshtastic_HardwareModel {
|
|||||||
meshtastic_HardwareModel_THINKNODE_M9 = 131,
|
meshtastic_HardwareModel_THINKNODE_M9 = 131,
|
||||||
/* The Heltec-V4-R8 uses an ESP32S3R8 chip, plus an SX1262. */
|
/* The Heltec-V4-R8 uses an ESP32S3R8 chip, plus an SX1262. */
|
||||||
meshtastic_HardwareModel_HELTEC_V4_R8 = 132,
|
meshtastic_HardwareModel_HELTEC_V4_R8 = 132,
|
||||||
|
/* The HELTEC_MESH_NODE_T1 uses an NRF52840 chip, plus an SX1262. */
|
||||||
|
meshtastic_HardwareModel_HELTEC_MESH_NODE_T1 = 133,
|
||||||
|
/* B&Q Consulting Station G3: TBD */
|
||||||
|
meshtastic_HardwareModel_STATION_G3 = 134,
|
||||||
/* ------------------------------------------------------------------------------------------------------------------------------------------
|
/* ------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits.
|
Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits.
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------ */
|
------------------------------------------------------------------------------------------------------------------------------------------ */
|
||||||
@@ -634,6 +638,25 @@ typedef enum _meshtastic_LogRecord_Level {
|
|||||||
meshtastic_LogRecord_Level_TRACE = 5
|
meshtastic_LogRecord_Level_TRACE = 5
|
||||||
} meshtastic_LogRecord_Level;
|
} meshtastic_LogRecord_Level;
|
||||||
|
|
||||||
|
typedef enum _meshtastic_LockdownStatus_State {
|
||||||
|
/* Default; should not be sent. */
|
||||||
|
meshtastic_LockdownStatus_State_STATE_UNSPECIFIED = 0,
|
||||||
|
/* No passphrase has ever been provisioned on this device.
|
||||||
|
Client should prompt the operator to set one. */
|
||||||
|
meshtastic_LockdownStatus_State_NEEDS_PROVISION = 1,
|
||||||
|
/* Storage is locked or this client has not authenticated yet.
|
||||||
|
lock_reason carries a machine-readable detail string.
|
||||||
|
Client should present (or auto-replay) a passphrase via
|
||||||
|
AdminMessage.lockdown_auth. */
|
||||||
|
meshtastic_LockdownStatus_State_LOCKED = 2,
|
||||||
|
/* Passphrase accepted; client is now authorized for this connection.
|
||||||
|
boots_remaining and valid_until_epoch describe the active session
|
||||||
|
token's TTL. */
|
||||||
|
meshtastic_LockdownStatus_State_UNLOCKED = 3,
|
||||||
|
/* Passphrase rejected. backoff_seconds is non-zero when rate-limited. */
|
||||||
|
meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4
|
||||||
|
} meshtastic_LockdownStatus_State;
|
||||||
|
|
||||||
/* Struct definitions */
|
/* Struct definitions */
|
||||||
/* A GPS Position */
|
/* A GPS Position */
|
||||||
typedef struct _meshtastic_Position {
|
typedef struct _meshtastic_Position {
|
||||||
@@ -1144,6 +1167,38 @@ typedef struct _meshtastic_QueueStatus {
|
|||||||
uint32_t mesh_packet_id;
|
uint32_t mesh_packet_id;
|
||||||
} meshtastic_QueueStatus;
|
} meshtastic_QueueStatus;
|
||||||
|
|
||||||
|
/* Lockdown state report from firmware to client (for hardened builds
|
||||||
|
with MESHTASTIC_LOCKDOWN). Sent immediately after config_complete_id
|
||||||
|
to inform a freshly-connected unauthorized client what it must do,
|
||||||
|
and again in response to each LockdownAuth admin command. */
|
||||||
|
typedef struct _meshtastic_LockdownStatus {
|
||||||
|
/* Current lockdown state being reported. */
|
||||||
|
meshtastic_LockdownStatus_State state;
|
||||||
|
/* For LOCKED: machine-readable reason. Known values:
|
||||||
|
"needs_auth" — storage already unlocked, client must auth
|
||||||
|
"token_missing" — no boot token on flash
|
||||||
|
"token_expired" — boot token wall-clock TTL elapsed
|
||||||
|
"token_boots_zero" — boot token boot-count TTL exhausted
|
||||||
|
"token_hmac_fail" — token tampered or wrong device
|
||||||
|
"token_dek_fail" — token DEK decrypt failed
|
||||||
|
"token_wrong_size" — token file corrupted
|
||||||
|
"token_bad_magic" — token file corrupted
|
||||||
|
"not_provisioned" — should generally use NEEDS_PROVISION state instead
|
||||||
|
Other values may be added; clients should treat unknown values as
|
||||||
|
"locked, ask for passphrase". */
|
||||||
|
char lock_reason[32];
|
||||||
|
/* For UNLOCKED: remaining boots on the issued session token.
|
||||||
|
Decrements by 1 on each subsequent boot. */
|
||||||
|
uint32_t boots_remaining;
|
||||||
|
/* For UNLOCKED: wall-clock expiry of the issued session token,
|
||||||
|
absolute Unix-epoch seconds. 0 = no time limit. */
|
||||||
|
uint32_t valid_until_epoch;
|
||||||
|
/* For UNLOCK_FAILED: seconds the client must wait before another
|
||||||
|
passphrase attempt will be accepted. 0 = wrong passphrase, no
|
||||||
|
backoff (immediate retry allowed but advisable to prompt user). */
|
||||||
|
uint32_t backoff_seconds;
|
||||||
|
} meshtastic_LockdownStatus;
|
||||||
|
|
||||||
typedef struct _meshtastic_KeyVerificationNumberInform {
|
typedef struct _meshtastic_KeyVerificationNumberInform {
|
||||||
uint64_t nonce;
|
uint64_t nonce;
|
||||||
char remote_longname[40];
|
char remote_longname[40];
|
||||||
@@ -1317,6 +1372,12 @@ typedef struct _meshtastic_FromRadio {
|
|||||||
meshtastic_ClientNotification clientNotification;
|
meshtastic_ClientNotification clientNotification;
|
||||||
/* Persistent data for device-ui */
|
/* Persistent data for device-ui */
|
||||||
meshtastic_DeviceUIConfig deviceuiConfig;
|
meshtastic_DeviceUIConfig deviceuiConfig;
|
||||||
|
/* Lockdown state notification for hardened firmware builds.
|
||||||
|
Sent post-config (so unauthorized clients learn they must
|
||||||
|
provision/unlock) and after each LockdownAuth admin command
|
||||||
|
to report success or failure. Replaces the earlier scheme of
|
||||||
|
encoding state as magic-string prefixes inside ClientNotification. */
|
||||||
|
meshtastic_LockdownStatus lockdown_status;
|
||||||
};
|
};
|
||||||
} meshtastic_FromRadio;
|
} meshtastic_FromRadio;
|
||||||
|
|
||||||
@@ -1458,6 +1519,10 @@ extern "C" {
|
|||||||
#define _meshtastic_LogRecord_Level_MAX meshtastic_LogRecord_Level_CRITICAL
|
#define _meshtastic_LogRecord_Level_MAX meshtastic_LogRecord_Level_CRITICAL
|
||||||
#define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1))
|
#define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1))
|
||||||
|
|
||||||
|
#define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED
|
||||||
|
#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_UNLOCK_FAILED
|
||||||
|
#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_UNLOCK_FAILED+1))
|
||||||
|
|
||||||
#define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource
|
#define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource
|
||||||
#define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource
|
#define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource
|
||||||
|
|
||||||
@@ -1488,6 +1553,8 @@ extern "C" {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#define meshtastic_LockdownStatus_state_ENUMTYPE meshtastic_LockdownStatus_State
|
||||||
|
|
||||||
#define meshtastic_ClientNotification_level_ENUMTYPE meshtastic_LogRecord_Level
|
#define meshtastic_ClientNotification_level_ENUMTYPE meshtastic_LogRecord_Level
|
||||||
|
|
||||||
|
|
||||||
@@ -1528,6 +1595,7 @@ extern "C" {
|
|||||||
#define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN}
|
#define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN}
|
||||||
#define meshtastic_QueueStatus_init_default {0, 0, 0, 0}
|
#define meshtastic_QueueStatus_init_default {0, 0, 0, 0}
|
||||||
#define meshtastic_FromRadio_init_default {0, 0, {meshtastic_MeshPacket_init_default}}
|
#define meshtastic_FromRadio_init_default {0, 0, {meshtastic_MeshPacket_init_default}}
|
||||||
|
#define meshtastic_LockdownStatus_init_default {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0}
|
||||||
#define meshtastic_ClientNotification_init_default {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_default}}
|
#define meshtastic_ClientNotification_init_default {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_default}}
|
||||||
#define meshtastic_KeyVerificationNumberInform_init_default {0, "", 0}
|
#define meshtastic_KeyVerificationNumberInform_init_default {0, "", 0}
|
||||||
#define meshtastic_KeyVerificationNumberRequest_init_default {0, ""}
|
#define meshtastic_KeyVerificationNumberRequest_init_default {0, ""}
|
||||||
@@ -1562,6 +1630,7 @@ extern "C" {
|
|||||||
#define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN}
|
#define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN}
|
||||||
#define meshtastic_QueueStatus_init_zero {0, 0, 0, 0}
|
#define meshtastic_QueueStatus_init_zero {0, 0, 0, 0}
|
||||||
#define meshtastic_FromRadio_init_zero {0, 0, {meshtastic_MeshPacket_init_zero}}
|
#define meshtastic_FromRadio_init_zero {0, 0, {meshtastic_MeshPacket_init_zero}}
|
||||||
|
#define meshtastic_LockdownStatus_init_zero {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0}
|
||||||
#define meshtastic_ClientNotification_init_zero {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_zero}}
|
#define meshtastic_ClientNotification_init_zero {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_zero}}
|
||||||
#define meshtastic_KeyVerificationNumberInform_init_zero {0, "", 0}
|
#define meshtastic_KeyVerificationNumberInform_init_zero {0, "", 0}
|
||||||
#define meshtastic_KeyVerificationNumberRequest_init_zero {0, ""}
|
#define meshtastic_KeyVerificationNumberRequest_init_zero {0, ""}
|
||||||
@@ -1714,6 +1783,11 @@ extern "C" {
|
|||||||
#define meshtastic_QueueStatus_free_tag 2
|
#define meshtastic_QueueStatus_free_tag 2
|
||||||
#define meshtastic_QueueStatus_maxlen_tag 3
|
#define meshtastic_QueueStatus_maxlen_tag 3
|
||||||
#define meshtastic_QueueStatus_mesh_packet_id_tag 4
|
#define meshtastic_QueueStatus_mesh_packet_id_tag 4
|
||||||
|
#define meshtastic_LockdownStatus_state_tag 1
|
||||||
|
#define meshtastic_LockdownStatus_lock_reason_tag 2
|
||||||
|
#define meshtastic_LockdownStatus_boots_remaining_tag 3
|
||||||
|
#define meshtastic_LockdownStatus_valid_until_epoch_tag 4
|
||||||
|
#define meshtastic_LockdownStatus_backoff_seconds_tag 5
|
||||||
#define meshtastic_KeyVerificationNumberInform_nonce_tag 1
|
#define meshtastic_KeyVerificationNumberInform_nonce_tag 1
|
||||||
#define meshtastic_KeyVerificationNumberInform_remote_longname_tag 2
|
#define meshtastic_KeyVerificationNumberInform_remote_longname_tag 2
|
||||||
#define meshtastic_KeyVerificationNumberInform_security_number_tag 3
|
#define meshtastic_KeyVerificationNumberInform_security_number_tag 3
|
||||||
@@ -1773,6 +1847,7 @@ extern "C" {
|
|||||||
#define meshtastic_FromRadio_fileInfo_tag 15
|
#define meshtastic_FromRadio_fileInfo_tag 15
|
||||||
#define meshtastic_FromRadio_clientNotification_tag 16
|
#define meshtastic_FromRadio_clientNotification_tag 16
|
||||||
#define meshtastic_FromRadio_deviceuiConfig_tag 17
|
#define meshtastic_FromRadio_deviceuiConfig_tag 17
|
||||||
|
#define meshtastic_FromRadio_lockdown_status_tag 18
|
||||||
#define meshtastic_Heartbeat_nonce_tag 1
|
#define meshtastic_Heartbeat_nonce_tag 1
|
||||||
#define meshtastic_ToRadio_packet_tag 1
|
#define meshtastic_ToRadio_packet_tag 1
|
||||||
#define meshtastic_ToRadio_want_config_id_tag 3
|
#define meshtastic_ToRadio_want_config_id_tag 3
|
||||||
@@ -2013,7 +2088,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,metadata,metadata), 13) \
|
|||||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mqttClientProxyMessage,mqttClientProxyMessage), 14) \
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mqttClientProxyMessage,mqttClientProxyMessage), 14) \
|
||||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \
|
||||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \
|
||||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17)
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) \
|
||||||
|
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18)
|
||||||
#define meshtastic_FromRadio_CALLBACK NULL
|
#define meshtastic_FromRadio_CALLBACK NULL
|
||||||
#define meshtastic_FromRadio_DEFAULT NULL
|
#define meshtastic_FromRadio_DEFAULT NULL
|
||||||
#define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket
|
#define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket
|
||||||
@@ -2030,6 +2106,16 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfi
|
|||||||
#define meshtastic_FromRadio_payload_variant_fileInfo_MSGTYPE meshtastic_FileInfo
|
#define meshtastic_FromRadio_payload_variant_fileInfo_MSGTYPE meshtastic_FileInfo
|
||||||
#define meshtastic_FromRadio_payload_variant_clientNotification_MSGTYPE meshtastic_ClientNotification
|
#define meshtastic_FromRadio_payload_variant_clientNotification_MSGTYPE meshtastic_ClientNotification
|
||||||
#define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig
|
#define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig
|
||||||
|
#define meshtastic_FromRadio_payload_variant_lockdown_status_MSGTYPE meshtastic_LockdownStatus
|
||||||
|
|
||||||
|
#define meshtastic_LockdownStatus_FIELDLIST(X, a) \
|
||||||
|
X(a, STATIC, SINGULAR, UENUM, state, 1) \
|
||||||
|
X(a, STATIC, SINGULAR, STRING, lock_reason, 2) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, boots_remaining, 3) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 4) \
|
||||||
|
X(a, STATIC, SINGULAR, UINT32, backoff_seconds, 5)
|
||||||
|
#define meshtastic_LockdownStatus_CALLBACK NULL
|
||||||
|
#define meshtastic_LockdownStatus_DEFAULT NULL
|
||||||
|
|
||||||
#define meshtastic_ClientNotification_FIELDLIST(X, a) \
|
#define meshtastic_ClientNotification_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, OPTIONAL, UINT32, reply_id, 1) \
|
X(a, STATIC, OPTIONAL, UINT32, reply_id, 1) \
|
||||||
@@ -2190,6 +2276,7 @@ extern const pb_msgdesc_t meshtastic_MyNodeInfo_msg;
|
|||||||
extern const pb_msgdesc_t meshtastic_LogRecord_msg;
|
extern const pb_msgdesc_t meshtastic_LogRecord_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_QueueStatus_msg;
|
extern const pb_msgdesc_t meshtastic_QueueStatus_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_FromRadio_msg;
|
extern const pb_msgdesc_t meshtastic_FromRadio_msg;
|
||||||
|
extern const pb_msgdesc_t meshtastic_LockdownStatus_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_ClientNotification_msg;
|
extern const pb_msgdesc_t meshtastic_ClientNotification_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_KeyVerificationNumberInform_msg;
|
extern const pb_msgdesc_t meshtastic_KeyVerificationNumberInform_msg;
|
||||||
extern const pb_msgdesc_t meshtastic_KeyVerificationNumberRequest_msg;
|
extern const pb_msgdesc_t meshtastic_KeyVerificationNumberRequest_msg;
|
||||||
@@ -2226,6 +2313,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
|
|||||||
#define meshtastic_LogRecord_fields &meshtastic_LogRecord_msg
|
#define meshtastic_LogRecord_fields &meshtastic_LogRecord_msg
|
||||||
#define meshtastic_QueueStatus_fields &meshtastic_QueueStatus_msg
|
#define meshtastic_QueueStatus_fields &meshtastic_QueueStatus_msg
|
||||||
#define meshtastic_FromRadio_fields &meshtastic_FromRadio_msg
|
#define meshtastic_FromRadio_fields &meshtastic_FromRadio_msg
|
||||||
|
#define meshtastic_LockdownStatus_fields &meshtastic_LockdownStatus_msg
|
||||||
#define meshtastic_ClientNotification_fields &meshtastic_ClientNotification_msg
|
#define meshtastic_ClientNotification_fields &meshtastic_ClientNotification_msg
|
||||||
#define meshtastic_KeyVerificationNumberInform_fields &meshtastic_KeyVerificationNumberInform_msg
|
#define meshtastic_KeyVerificationNumberInform_fields &meshtastic_KeyVerificationNumberInform_msg
|
||||||
#define meshtastic_KeyVerificationNumberRequest_fields &meshtastic_KeyVerificationNumberRequest_msg
|
#define meshtastic_KeyVerificationNumberRequest_fields &meshtastic_KeyVerificationNumberRequest_msg
|
||||||
@@ -2261,6 +2349,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
|
|||||||
#define meshtastic_KeyVerificationNumberInform_size 58
|
#define meshtastic_KeyVerificationNumberInform_size 58
|
||||||
#define meshtastic_KeyVerificationNumberRequest_size 52
|
#define meshtastic_KeyVerificationNumberRequest_size 52
|
||||||
#define meshtastic_KeyVerification_size 79
|
#define meshtastic_KeyVerification_size 79
|
||||||
|
#define meshtastic_LockdownStatus_size 53
|
||||||
#define meshtastic_LogRecord_size 426
|
#define meshtastic_LogRecord_size 426
|
||||||
#define meshtastic_LowEntropyKey_size 0
|
#define meshtastic_LowEntropyKey_size 0
|
||||||
#define meshtastic_MeshPacket_size 381
|
#define meshtastic_MeshPacket_size 381
|
||||||
|
|||||||
@@ -115,7 +115,11 @@ typedef enum _meshtastic_TelemetrySensorType {
|
|||||||
/* SHT family of sensors for temperature and humidity */
|
/* SHT family of sensors for temperature and humidity */
|
||||||
meshtastic_TelemetrySensorType_SHTXX = 50,
|
meshtastic_TelemetrySensorType_SHTXX = 50,
|
||||||
/* DS248X Bridge for one-wire temperature sensors */
|
/* DS248X Bridge for one-wire temperature sensors */
|
||||||
meshtastic_TelemetrySensorType_DS248X = 51
|
meshtastic_TelemetrySensorType_DS248X = 51,
|
||||||
|
/* MMC5983MA 3-Axis Digital Magnetic Sensor */
|
||||||
|
meshtastic_TelemetrySensorType_MMC5983MA = 52,
|
||||||
|
/* ICM-42607-P 6‑Axis IMU */
|
||||||
|
meshtastic_TelemetrySensorType_ICM42607P = 53
|
||||||
} meshtastic_TelemetrySensorType;
|
} meshtastic_TelemetrySensorType;
|
||||||
|
|
||||||
/* Struct definitions */
|
/* Struct definitions */
|
||||||
@@ -496,8 +500,8 @@ extern "C" {
|
|||||||
|
|
||||||
/* Helper constants for enums */
|
/* Helper constants for enums */
|
||||||
#define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET
|
#define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET
|
||||||
#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_DS248X
|
#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_ICM42607P
|
||||||
#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_DS248X+1))
|
#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_ICM42607P+1))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ void handleNodes(HTTPRequest *req, HTTPResponse *res)
|
|||||||
uint32_t readIndex = 0;
|
uint32_t readIndex = 0;
|
||||||
const meshtastic_NodeInfoLite *tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
const meshtastic_NodeInfoLite *tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
||||||
while (tempNodeInfo != NULL) {
|
while (tempNodeInfo != NULL) {
|
||||||
if (tempNodeInfo->has_user) {
|
if (nodeInfoLiteHasUser(tempNodeInfo)) {
|
||||||
JSONObject node;
|
JSONObject node;
|
||||||
|
|
||||||
char id[16];
|
char id[16];
|
||||||
@@ -737,26 +737,26 @@ void handleNodes(HTTPRequest *req, HTTPResponse *res)
|
|||||||
|
|
||||||
node["id"] = new JSONValue(id);
|
node["id"] = new JSONValue(id);
|
||||||
node["snr"] = new JSONValue(tempNodeInfo->snr);
|
node["snr"] = new JSONValue(tempNodeInfo->snr);
|
||||||
node["via_mqtt"] = new JSONValue(BoolToString(tempNodeInfo->via_mqtt));
|
node["via_mqtt"] = new JSONValue(BoolToString(nodeInfoLiteViaMqtt(tempNodeInfo)));
|
||||||
node["last_heard"] = new JSONValue((int)tempNodeInfo->last_heard);
|
node["last_heard"] = new JSONValue((int)tempNodeInfo->last_heard);
|
||||||
node["position"] = new JSONValue();
|
node["position"] = new JSONValue();
|
||||||
|
|
||||||
if (nodeDB->hasValidPosition(tempNodeInfo)) {
|
if (nodeDB->hasValidPosition(tempNodeInfo)) {
|
||||||
JSONObject position;
|
meshtastic_PositionLite posLite;
|
||||||
position["latitude"] = new JSONValue((float)tempNodeInfo->position.latitude_i * 1e-7);
|
if (nodeDB->copyNodePosition(tempNodeInfo->num, posLite)) {
|
||||||
position["longitude"] = new JSONValue((float)tempNodeInfo->position.longitude_i * 1e-7);
|
JSONObject position;
|
||||||
position["altitude"] = new JSONValue((int)tempNodeInfo->position.altitude);
|
position["latitude"] = new JSONValue((float)posLite.latitude_i * 1e-7);
|
||||||
node["position"] = new JSONValue(position);
|
position["longitude"] = new JSONValue((float)posLite.longitude_i * 1e-7);
|
||||||
|
position["altitude"] = new JSONValue((int)posLite.altitude);
|
||||||
|
node["position"] = new JSONValue(position);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node["long_name"] = new JSONValue(tempNodeInfo->user.long_name);
|
node["long_name"] = new JSONValue(tempNodeInfo->long_name);
|
||||||
node["short_name"] = new JSONValue(tempNodeInfo->user.short_name);
|
node["short_name"] = new JSONValue(tempNodeInfo->short_name);
|
||||||
char macStr[18];
|
// mac_address dropped from NodeInfoLite as part of the slim refactor; emit zeros.
|
||||||
snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", tempNodeInfo->user.macaddr[0],
|
node["mac_address"] = new JSONValue("00:00:00:00:00:00");
|
||||||
tempNodeInfo->user.macaddr[1], tempNodeInfo->user.macaddr[2], tempNodeInfo->user.macaddr[3],
|
node["hw_model"] = new JSONValue(tempNodeInfo->hw_model);
|
||||||
tempNodeInfo->user.macaddr[4], tempNodeInfo->user.macaddr[5]);
|
|
||||||
node["mac_address"] = new JSONValue(macStr);
|
|
||||||
node["hw_model"] = new JSONValue(tempNodeInfo->user.hw_model);
|
|
||||||
|
|
||||||
nodesArray.push_back(new JSONValue(node));
|
nodesArray.push_back(new JSONValue(node));
|
||||||
}
|
}
|
||||||
@@ -930,4 +930,4 @@ void handleScanNetworks(HTTPRequest *req, HTTPResponse *res)
|
|||||||
res->print(jsonString.c_str());
|
res->print(jsonString.c_str());
|
||||||
delete value;
|
delete value;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -37,16 +37,50 @@
|
|||||||
#define MAX_RX_NOTIFICATION_TOPHONE 2
|
#define MAX_RX_NOTIFICATION_TOPHONE 2
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Verify baseline assumption of node size. If it increases, we need to reevaluate
|
/// Tighten this when the slim header shrinks; loosen only with deliberate
|
||||||
/// the impact of its memory footprint, notably on MAX_NUM_NODES.
|
/// awareness of MAX_NUM_NODES impact per platform.
|
||||||
static_assert(sizeof(meshtastic_NodeInfoLite) <= 200, "NodeInfoLite size increased. Reconsider impact on MAX_NUM_NODES.");
|
static_assert(sizeof(meshtastic_NodeInfoLite) <= 130, "NodeInfoLite size increased. Reconsider impact on MAX_NUM_NODES.");
|
||||||
|
|
||||||
|
// Compile satellite NodeDBs out on STM32WL (and the status DB also follows
|
||||||
|
// MESHTASTIC_EXCLUDE_STATUS).
|
||||||
|
#ifndef MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
#if defined(ARCH_STM32WL)
|
||||||
|
#define MESHTASTIC_EXCLUDE_POSITIONDB 1
|
||||||
|
#else
|
||||||
|
#define MESHTASTIC_EXCLUDE_POSITIONDB 0
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
#if defined(ARCH_STM32WL)
|
||||||
|
#define MESHTASTIC_EXCLUDE_TELEMETRYDB 1
|
||||||
|
#else
|
||||||
|
#define MESHTASTIC_EXCLUDE_TELEMETRYDB 0
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
#if defined(ARCH_STM32WL)
|
||||||
|
#define MESHTASTIC_EXCLUDE_ENVIRONMENTDB 1
|
||||||
|
#else
|
||||||
|
#define MESHTASTIC_EXCLUDE_ENVIRONMENTDB 0
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
#if defined(ARCH_STM32WL) || defined(MESHTASTIC_EXCLUDE_STATUS)
|
||||||
|
#define MESHTASTIC_EXCLUDE_STATUSDB 1
|
||||||
|
#else
|
||||||
|
#define MESHTASTIC_EXCLUDE_STATUSDB 0
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
/// max number of nodes allowed in the nodeDB
|
/// max number of nodes allowed in the nodeDB
|
||||||
#ifndef MAX_NUM_NODES
|
#ifndef MAX_NUM_NODES
|
||||||
#if defined(ARCH_STM32WL)
|
#if defined(ARCH_STM32WL)
|
||||||
#define MAX_NUM_NODES 10
|
#define MAX_NUM_NODES 10
|
||||||
#elif defined(ARCH_NRF52)
|
#elif defined(ARCH_NRF52)
|
||||||
#define MAX_NUM_NODES 80
|
#define MAX_NUM_NODES 150
|
||||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
|
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||||
#include "Esp.h"
|
#include "Esp.h"
|
||||||
static inline int get_max_num_nodes()
|
static inline int get_max_num_nodes()
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
#define ETH ETH2
|
#define ETH ETH2
|
||||||
#endif // HAS_ETHERNET
|
#endif // HAS_ETHERNET
|
||||||
|
|
||||||
|
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||||
|
#include "ESP32_CH390.h"
|
||||||
|
#include "hal/spi_types.h"
|
||||||
|
#define ETH CH390
|
||||||
|
#endif // HAS_ETHERNET
|
||||||
|
|
||||||
#include <WiFiUdp.h>
|
#include <WiFiUdp.h>
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||||
@@ -56,12 +62,43 @@ unsigned long lastrun_ntp = 0;
|
|||||||
|
|
||||||
bool needReconnect = true; // If we create our reconnector, run it once at the beginning
|
bool needReconnect = true; // If we create our reconnector, run it once at the beginning
|
||||||
bool isReconnecting = false; // If we are currently reconnecting
|
bool isReconnecting = false; // If we are currently reconnecting
|
||||||
|
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
|
static volatile bool ethNetworkConnectedPending = false;
|
||||||
|
#endif
|
||||||
|
|
||||||
WiFiUDP syslogClient;
|
WiFiUDP syslogClient;
|
||||||
meshtastic::Syslog syslog(syslogClient);
|
meshtastic::Syslog syslog(syslogClient);
|
||||||
|
|
||||||
Periodic *wifiReconnect;
|
Periodic *wifiReconnect;
|
||||||
|
|
||||||
|
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
|
static void onNetworkConnected();
|
||||||
|
static uint32_t lastEthIP = 0;
|
||||||
|
static int32_t ethNetworkConnectedPoll()
|
||||||
|
{
|
||||||
|
if (ethNetworkConnectedPending) {
|
||||||
|
ethNetworkConnectedPending = false;
|
||||||
|
uint32_t ip = (uint32_t)ETH.localIP();
|
||||||
|
bool ipChanged = APStartupComplete && ip != 0 && ip != lastEthIP;
|
||||||
|
onNetworkConnected();
|
||||||
|
if (ipChanged) {
|
||||||
|
LOG_INFO("Ethernet IP changed (%u.%u.%u.%u), restarting mDNS", ip & 0xff, (ip >> 8) & 0xff, (ip >> 16) & 0xff,
|
||||||
|
(ip >> 24) & 0xff);
|
||||||
|
MDNS.end();
|
||||||
|
if (MDNS.begin("Meshtastic")) {
|
||||||
|
MDNS.addService("meshtastic", "tcp", SERVER_API_DEFAULT_PORT);
|
||||||
|
MDNS.addServiceTxt("meshtastic", "tcp", "shortname", String(owner.short_name));
|
||||||
|
MDNS.addServiceTxt("meshtastic", "tcp", "id", String(nodeDB->getNodeId().c_str()));
|
||||||
|
MDNS.addServiceTxt("meshtastic", "tcp", "pio_env", optstr(APP_ENV));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ip != 0)
|
||||||
|
lastEthIP = ip;
|
||||||
|
}
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef USE_WS5500
|
#ifdef USE_WS5500
|
||||||
// Startup Ethernet
|
// Startup Ethernet
|
||||||
bool initEthernet()
|
bool initEthernet()
|
||||||
@@ -72,6 +109,38 @@ bool initEthernet()
|
|||||||
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||||
createSSLCert(); // For WebServer
|
createSSLCert(); // For WebServer
|
||||||
#endif
|
#endif
|
||||||
|
new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef USE_CH390D
|
||||||
|
// Startup Ethernet
|
||||||
|
bool initEthernet()
|
||||||
|
{
|
||||||
|
// Configure CH390
|
||||||
|
ch390_config_t ch390_conf = CH390_DEFAULT_CONFIG();
|
||||||
|
ch390_conf.spi_host = SPI3_HOST;
|
||||||
|
ch390_conf.spi_cs_gpio = ETH_CS_PIN;
|
||||||
|
ch390_conf.spi_sck_gpio = ETH_SCLK_PIN;
|
||||||
|
ch390_conf.spi_mosi_gpio = ETH_MOSI_PIN;
|
||||||
|
ch390_conf.spi_miso_gpio = ETH_MISO_PIN;
|
||||||
|
ch390_conf.int_gpio = ETH_INT_PIN;
|
||||||
|
#ifdef ETH_RST_PIN
|
||||||
|
ch390_conf.reset_gpio = ETH_RST_PIN;
|
||||||
|
#else
|
||||||
|
ch390_conf.reset_gpio = -1;
|
||||||
|
#endif
|
||||||
|
ch390_conf.spi_clock_mhz = 20;
|
||||||
|
if ((config.network.eth_enabled) && (ETH.begin(ch390_conf))) {
|
||||||
|
WiFi.onEvent(WiFiEvent);
|
||||||
|
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||||
|
createSSLCert(); // For WebServer
|
||||||
|
#endif
|
||||||
|
new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,10 +291,8 @@ static int32_t reconnectWiFi()
|
|||||||
#endif
|
#endif
|
||||||
return 1000; // check once per second
|
return 1000; // check once per second
|
||||||
} else {
|
} else {
|
||||||
#ifdef ARCH_RP2040
|
onNetworkConnected(); // will only do anything once (guarded by APStartupComplete)
|
||||||
onNetworkConnected(); // will only do anything once
|
return 300000; // every 5 minutes
|
||||||
#endif
|
|
||||||
return 300000; // every 5 minutes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +301,7 @@ bool isWifiAvailable()
|
|||||||
|
|
||||||
if (config.network.wifi_enabled && (config.network.wifi_ssid[0])) {
|
if (config.network.wifi_enabled && (config.network.wifi_ssid[0])) {
|
||||||
return true;
|
return true;
|
||||||
#ifdef USE_WS5500
|
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
} else if (config.network.eth_enabled) {
|
} else if (config.network.eth_enabled) {
|
||||||
return true;
|
return true;
|
||||||
#endif
|
#endif
|
||||||
@@ -274,9 +341,6 @@ bool initWifi()
|
|||||||
const char *wifiPsw = config.network.wifi_psk;
|
const char *wifiPsw = config.network.wifi_psk;
|
||||||
|
|
||||||
#ifndef ARCH_RP2040
|
#ifndef ARCH_RP2040
|
||||||
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
|
||||||
createSSLCert(); // For WebServer
|
|
||||||
#endif
|
|
||||||
WiFi.persistent(false); // Disable flash storage for WiFi credentials
|
WiFi.persistent(false); // Disable flash storage for WiFi credentials
|
||||||
#endif
|
#endif
|
||||||
if (!*wifiPsw) // Treat empty password as no password
|
if (!*wifiPsw) // Treat empty password as no password
|
||||||
@@ -301,6 +365,9 @@ bool initWifi()
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
|
// Register WiFi event handler BEFORE createSSLCert() to prevent race condition:
|
||||||
|
// Without this, WiFi can auto-reconnect during cert generation and fire GOT_IP
|
||||||
|
// before the handler is registered, causing onNetworkConnected() to never run.
|
||||||
WiFi.onEvent(WiFiEvent);
|
WiFi.onEvent(WiFiEvent);
|
||||||
WiFi.setAutoReconnect(true);
|
WiFi.setAutoReconnect(true);
|
||||||
WiFi.setSleep(false);
|
WiFi.setSleep(false);
|
||||||
@@ -322,6 +389,12 @@ bool initWifi()
|
|||||||
wifiDisconnectReason = info.wifi_sta_disconnected.reason;
|
wifiDisconnectReason = info.wifi_sta_disconnected.reason;
|
||||||
},
|
},
|
||||||
WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
|
WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef ARCH_RP2040
|
||||||
|
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||||
|
createSSLCert(); // For WebServer - called after WiFi.onEvent() to avoid race condition
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
LOG_DEBUG("JOINING WIFI soon: ssid=%s", wifiName);
|
LOG_DEBUG("JOINING WIFI soon: ssid=%s", wifiName);
|
||||||
wifiReconnect = new Periodic("WifiConnect", reconnectWiFi);
|
wifiReconnect = new Periodic("WifiConnect", reconnectWiFi);
|
||||||
@@ -384,13 +457,13 @@ static void WiFiEvent(WiFiEvent_t event)
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#ifdef WIFI_LED
|
#ifdef WIFI_LED
|
||||||
digitalWrite(WIFI_LED, HIGH);
|
digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON);
|
||||||
#endif
|
#endif
|
||||||
break;
|
break;
|
||||||
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
|
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
|
||||||
LOG_INFO("Disconnected from WiFi access point");
|
LOG_INFO("Disconnected from WiFi access point");
|
||||||
#ifdef WIFI_LED
|
#ifdef WIFI_LED
|
||||||
digitalWrite(WIFI_LED, LOW);
|
digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON);
|
||||||
#endif
|
#endif
|
||||||
#if HAS_UDP_MULTICAST
|
#if HAS_UDP_MULTICAST
|
||||||
if (udpHandler) {
|
if (udpHandler) {
|
||||||
@@ -452,13 +525,13 @@ static void WiFiEvent(WiFiEvent_t event)
|
|||||||
case ARDUINO_EVENT_WIFI_AP_START:
|
case ARDUINO_EVENT_WIFI_AP_START:
|
||||||
LOG_INFO("WiFi access point started");
|
LOG_INFO("WiFi access point started");
|
||||||
#ifdef WIFI_LED
|
#ifdef WIFI_LED
|
||||||
digitalWrite(WIFI_LED, HIGH);
|
digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON);
|
||||||
#endif
|
#endif
|
||||||
break;
|
break;
|
||||||
case ARDUINO_EVENT_WIFI_AP_STOP:
|
case ARDUINO_EVENT_WIFI_AP_STOP:
|
||||||
LOG_INFO("WiFi access point stopped");
|
LOG_INFO("WiFi access point stopped");
|
||||||
#ifdef WIFI_LED
|
#ifdef WIFI_LED
|
||||||
digitalWrite(WIFI_LED, LOW);
|
digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON);
|
||||||
#endif
|
#endif
|
||||||
break;
|
break;
|
||||||
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
|
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
|
||||||
@@ -494,18 +567,18 @@ static void WiFiEvent(WiFiEvent_t event)
|
|||||||
LOG_INFO("Ethernet disconnected");
|
LOG_INFO("Ethernet disconnected");
|
||||||
break;
|
break;
|
||||||
case ARDUINO_EVENT_ETH_GOT_IP:
|
case ARDUINO_EVENT_ETH_GOT_IP:
|
||||||
#ifdef USE_WS5500
|
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
LOG_INFO("Obtained IP address: %s, %u Mbps, %s", ETH.localIP().toString().c_str(), ETH.linkSpeed(),
|
LOG_INFO("Obtained IP address: %s, %u Mbps, %s", ETH.localIP().toString().c_str(), ETH.linkSpeed(),
|
||||||
ETH.fullDuplex() ? "FULL_DUPLEX" : "HALF_DUPLEX");
|
ETH.fullDuplex() ? "FULL_DUPLEX" : "HALF_DUPLEX");
|
||||||
onNetworkConnected();
|
ethNetworkConnectedPending = true;
|
||||||
#endif
|
#endif
|
||||||
break;
|
break;
|
||||||
case ARDUINO_EVENT_ETH_GOT_IP6:
|
case ARDUINO_EVENT_ETH_GOT_IP6:
|
||||||
#ifdef USE_WS5500
|
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
|
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
|
||||||
LOG_INFO("Obtained Local IP6 address: %s", ETH.linkLocalIPv6().toString().c_str());
|
LOG_INFO("Obtained Local IP6 address: %s", ETH.linkLocalIPv6().toString().c_str());
|
||||||
LOG_INFO("Obtained GlobalIP6 address: %s", ETH.globalIPv6().toString().c_str());
|
LOG_INFO("Obtained GlobalIP6 address: %s", ETH.globalIPv6().toString().c_str());
|
||||||
#else
|
#elif defined(USE_WS5500)
|
||||||
LOG_INFO("Obtained IP6 address: %s", ETH.localIPv6().toString().c_str());
|
LOG_INFO("Obtained IP6 address: %s", ETH.localIPv6().toString().c_str());
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ bool isWifiAvailable();
|
|||||||
|
|
||||||
uint8_t getWifiDisconnectReason();
|
uint8_t getWifiDisconnectReason();
|
||||||
|
|
||||||
#ifdef USE_WS5500
|
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
// Startup Ethernet
|
// Startup Ethernet
|
||||||
bool initEthernet();
|
bool initEthernet();
|
||||||
#endif
|
#endif
|
||||||
+16
-15
@@ -107,14 +107,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
|||||||
|
|
||||||
// Automatically favorite the node that is using the admin key
|
// Automatically favorite the node that is using the admin key
|
||||||
auto remoteNode = nodeDB->getMeshNode(mp.from);
|
auto remoteNode = nodeDB->getMeshNode(mp.from);
|
||||||
if (remoteNode && !remoteNode->is_favorite) {
|
if (remoteNode && !nodeInfoLiteIsFavorite(remoteNode)) {
|
||||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) {
|
if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) {
|
||||||
// Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it
|
// Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it
|
||||||
// without the user doing so deliberately.
|
// without the user doing so deliberately.
|
||||||
LOG_INFO("PKC admin valid, but not auto-favoriting node %x because role==CLIENT_BASE", mp.from);
|
LOG_INFO("PKC admin valid, but not auto-favoriting node %x because role==CLIENT_BASE", mp.from);
|
||||||
} else {
|
} else {
|
||||||
LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from);
|
LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from);
|
||||||
remoteNode->is_favorite = true;
|
nodeInfoLiteSetBit(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -393,7 +393,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
|||||||
LOG_INFO("Client received set_favorite_node command");
|
LOG_INFO("Client received set_favorite_node command");
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node);
|
||||||
if (node != NULL) {
|
if (node != NULL) {
|
||||||
node->is_favorite = true;
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||||
if (screen)
|
if (screen)
|
||||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
||||||
@@ -404,7 +404,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
|||||||
LOG_INFO("Client received remove_favorite_node command");
|
LOG_INFO("Client received remove_favorite_node command");
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_favorite_node);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_favorite_node);
|
||||||
if (node != NULL) {
|
if (node != NULL) {
|
||||||
node->is_favorite = false;
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false);
|
||||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||||
if (screen)
|
if (screen)
|
||||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
||||||
@@ -415,11 +415,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
|||||||
LOG_INFO("Client received set_ignored_node command");
|
LOG_INFO("Client received set_ignored_node command");
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_ignored_node);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_ignored_node);
|
||||||
if (node != NULL) {
|
if (node != NULL) {
|
||||||
node->is_ignored = true;
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||||
node->has_device_metrics = false;
|
nodeDB->eraseNodeSatellites(node->num);
|
||||||
node->has_position = false;
|
node->public_key.size = 0;
|
||||||
node->user.public_key.size = 0;
|
memset(node->public_key.bytes, 0, sizeof(node->public_key.bytes));
|
||||||
memset(node->user.public_key.bytes, 0, sizeof(node->user.public_key.bytes));
|
|
||||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -428,7 +427,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
|||||||
LOG_INFO("Client received remove_ignored_node command");
|
LOG_INFO("Client received remove_ignored_node command");
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_ignored_node);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_ignored_node);
|
||||||
if (node != NULL) {
|
if (node != NULL) {
|
||||||
node->is_ignored = false;
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
|
||||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -437,7 +436,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
|||||||
LOG_INFO("Client received toggle_muted_node command");
|
LOG_INFO("Client received toggle_muted_node command");
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->toggle_muted_node);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->toggle_muted_node);
|
||||||
if (node != NULL) {
|
if (node != NULL) {
|
||||||
node->bitfield ^= (1 << NODEINFO_BITFIELD_IS_MUTED_SHIFT);
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_MUTED_MASK, !nodeInfoLiteIsMuted(node));
|
||||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -445,9 +444,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
|||||||
|
|
||||||
case meshtastic_AdminMessage_set_fixed_position_tag: {
|
case meshtastic_AdminMessage_set_fixed_position_tag: {
|
||||||
LOG_INFO("Client received set_fixed_position command");
|
LOG_INFO("Client received set_fixed_position command");
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
node->has_position = true;
|
// Route the fixed position through updatePosition so it lands in the
|
||||||
node->position = TypeConversions::ConvertToPositionLite(r->set_fixed_position);
|
// satellite map (or, on builds with PositionDB excluded, just sets
|
||||||
|
// localPosition for the local broadcast path).
|
||||||
|
nodeDB->updatePosition(node->num, r->set_fixed_position, RX_SRC_LOCAL);
|
||||||
nodeDB->setLocalPosition(r->set_fixed_position);
|
nodeDB->setLocalPosition(r->set_fixed_position);
|
||||||
config.position.fixed_position = true;
|
config.position.fixed_position = true;
|
||||||
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
|
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
|
||||||
@@ -1317,7 +1318,7 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||||
conn.has_ethernet = true;
|
conn.has_ethernet = true;
|
||||||
conn.ethernet.has_status = true;
|
conn.ethernet.has_status = true;
|
||||||
if (Ethernet.linkStatus() == LinkON) {
|
if (Ethernet.linkStatus() == LinkON) {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t
|
|||||||
static bool returnToCannedList = false;
|
static bool returnToCannedList = false;
|
||||||
bool hasKeyForNode(const meshtastic_NodeInfoLite *node)
|
bool hasKeyForNode(const meshtastic_NodeInfoLite *node)
|
||||||
{
|
{
|
||||||
return node && node->has_user && node->user.public_key.size > 0;
|
return nodeInfoLiteHasUser(node) && node->public_key.size > 0;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @brief Items in array this->messages will be set to be pointing on the right
|
* @brief Items in array this->messages will be set to be pointing on the right
|
||||||
@@ -262,10 +262,10 @@ void CannedMessageModule::updateDestinationSelectionList()
|
|||||||
|
|
||||||
for (size_t i = 0; i < numMeshNodes; ++i) {
|
for (size_t i = 0; i < numMeshNodes; ++i) {
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
||||||
if (!node || node->num == myNodeNum || !node->has_user || node->user.public_key.size != 32)
|
if (!node || node->num == myNodeNum || !nodeInfoLiteHasUser(node) || node->public_key.size != 32)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const String &nodeName = node->user.long_name;
|
const String &nodeName = node->long_name;
|
||||||
|
|
||||||
if (searchQuery.length() == 0) {
|
if (searchQuery.length() == 0) {
|
||||||
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
||||||
@@ -1054,7 +1054,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
|
|||||||
}
|
}
|
||||||
|
|
||||||
NodeNum myNodeNum = nodeDB->getNodeNum();
|
NodeNum myNodeNum = nodeDB->getNodeNum();
|
||||||
if (node && node->num != myNodeNum && node->has_user && node->user.public_key.size == 32) {
|
if (node && node->num != myNodeNum && nodeInfoLiteHasUser(node) && node->public_key.size == 32) {
|
||||||
p->pki_encrypted = true;
|
p->pki_encrypted = true;
|
||||||
p->channel = 0; // force PKI
|
p->channel = 0; // force PKI
|
||||||
}
|
}
|
||||||
@@ -1415,8 +1415,8 @@ const char *CannedMessageModule::getNodeName(NodeNum node)
|
|||||||
return "Broadcast";
|
return "Broadcast";
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node);
|
meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node);
|
||||||
if (info && info->has_user && strlen(info->user.long_name) > 0) {
|
if (nodeInfoLiteHasUser(info) && strlen(info->long_name) > 0) {
|
||||||
return info->user.long_name;
|
return info->long_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
static char fallback[12];
|
static char fallback[12];
|
||||||
@@ -1723,17 +1723,17 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
|||||||
meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
|
meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
|
||||||
if (node) {
|
if (node) {
|
||||||
if (display->getWidth() <= 64) {
|
if (display->getWidth() <= 64) {
|
||||||
entryText = node->user.short_name;
|
entryText = node->short_name;
|
||||||
} else if (node->user.long_name[0]) {
|
} else if (node->long_name[0]) {
|
||||||
entryText = node->user.long_name;
|
entryText = node->long_name;
|
||||||
} else {
|
} else {
|
||||||
entryText = node->user.short_name;
|
entryText = node->short_name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int availWidth = display->getWidth() -
|
int availWidth = display->getWidth() -
|
||||||
((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) -
|
((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) -
|
||||||
((node && node->is_favorite) ? 10 : 0);
|
((nodeInfoLiteIsFavorite(node)) ? 10 : 0);
|
||||||
if (availWidth < 0)
|
if (availWidth < 0)
|
||||||
availWidth = 0;
|
availWidth = 0;
|
||||||
char truncatedEntry[96];
|
char truncatedEntry[96];
|
||||||
@@ -1742,7 +1742,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
|||||||
entryText = truncatedEntry;
|
entryText = truncatedEntry;
|
||||||
|
|
||||||
// Prepend "* " if this is a favorite
|
// Prepend "* " if this is a favorite
|
||||||
if (node && node->is_favorite) {
|
if (nodeInfoLiteIsFavorite(node)) {
|
||||||
entryText = "* " + entryText;
|
entryText = "* " + entryText;
|
||||||
}
|
}
|
||||||
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
|
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP
|
|||||||
// If we receive a direct message and the receipent is us, apply DM mute setting
|
// If we receive a direct message and the receipent is us, apply DM mute setting
|
||||||
// Else we just handle it as not muted.
|
// Else we just handle it as not muted.
|
||||||
const bool isDmToUs = !isBroadcast(mp.to) && isToUs(&mp);
|
const bool isDmToUs = !isBroadcast(mp.to) && isToUs(&mp);
|
||||||
bool is_muted = isDmToUs ? (sender && ((sender->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0))
|
bool is_muted = isDmToUs ? nodeInfoLiteIsMuted(sender)
|
||||||
: (ch.settings.has_module_settings && ch.settings.module_settings.is_muted);
|
: (ch.settings.has_module_settings && ch.settings.module_settings.is_muted);
|
||||||
|
|
||||||
const bool buzzerModeIsDirectOnly =
|
const bool buzzerModeIsDirectOnly =
|
||||||
|
|||||||
@@ -10,6 +10,18 @@
|
|||||||
|
|
||||||
KeyVerificationModule *keyVerificationModule;
|
KeyVerificationModule *keyVerificationModule;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
void copyNodeLongNameOrUnknown(char *dest, size_t destSize, const meshtastic_NodeInfoLite *node)
|
||||||
|
{
|
||||||
|
if (!dest || destSize == 0)
|
||||||
|
return;
|
||||||
|
const char *name = (node && nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : "Unknown";
|
||||||
|
strncpy(dest, name, destSize - 1);
|
||||||
|
dest[destSize - 1] = '\0';
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
KeyVerificationModule::KeyVerificationModule()
|
KeyVerificationModule::KeyVerificationModule()
|
||||||
: ProtobufModule("KeyVerification", meshtastic_PortNum_KEY_VERIFICATION_APP, &meshtastic_KeyVerification_msg)
|
: ProtobufModule("KeyVerification", meshtastic_PortNum_KEY_VERIFICATION_APP, &meshtastic_KeyVerification_msg)
|
||||||
{
|
{
|
||||||
@@ -37,7 +49,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons
|
|||||||
} else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY &&
|
} else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY &&
|
||||||
request->key_verification.nonce == currentNonce) {
|
request->key_verification.nonce == currentNonce) {
|
||||||
auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
|
auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
|
||||||
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
if (remoteNodePtr)
|
||||||
|
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||||
resetToIdle();
|
resetToIdle();
|
||||||
} else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) {
|
} else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) {
|
||||||
resetToIdle();
|
resetToIdle();
|
||||||
@@ -72,9 +85,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
|
|||||||
sprintf(cn->message, "Enter Security Number for Key Verification");
|
sprintf(cn->message, "Enter Security Number for Key Verification");
|
||||||
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_request_tag;
|
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_request_tag;
|
||||||
cn->payload_variant.key_verification_number_request.nonce = currentNonce;
|
cn->payload_variant.key_verification_number_request.nonce = currentNonce;
|
||||||
strncpy(cn->payload_variant.key_verification_number_request.remote_longname, // should really check for nulls, etc
|
copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_number_request.remote_longname,
|
||||||
nodeDB->getMeshNode(currentRemoteNode)->user.long_name,
|
sizeof(cn->payload_variant.key_verification_number_request.remote_longname),
|
||||||
sizeof(cn->payload_variant.key_verification_number_request.remote_longname));
|
nodeDB->getMeshNode(currentRemoteNode));
|
||||||
service->sendClientNotification(cn);
|
service->sendClientNotification(cn);
|
||||||
LOG_INFO("Received hash2");
|
LOG_INFO("Received hash2");
|
||||||
currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER;
|
currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER;
|
||||||
@@ -95,7 +108,8 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
|
|||||||
[=](int selected) {
|
[=](int selected) {
|
||||||
if (selected == 1) {
|
if (selected == 1) {
|
||||||
auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
|
auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
|
||||||
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
if (remoteNodePtr)
|
||||||
|
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
screen->showOverlayBanner(options);)
|
screen->showOverlayBanner(options);)
|
||||||
@@ -104,9 +118,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
|
|||||||
sprintf(cn->message, "Final confirmation for incoming manual key verification %s", message);
|
sprintf(cn->message, "Final confirmation for incoming manual key verification %s", message);
|
||||||
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag;
|
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag;
|
||||||
cn->payload_variant.key_verification_final.nonce = currentNonce;
|
cn->payload_variant.key_verification_final.nonce = currentNonce;
|
||||||
strncpy(cn->payload_variant.key_verification_final.remote_longname, // should really check for nulls, etc
|
copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_final.remote_longname,
|
||||||
nodeDB->getMeshNode(currentRemoteNode)->user.long_name,
|
sizeof(cn->payload_variant.key_verification_final.remote_longname),
|
||||||
sizeof(cn->payload_variant.key_verification_final.remote_longname));
|
nodeDB->getMeshNode(currentRemoteNode));
|
||||||
cn->payload_variant.key_verification_final.isSender = false;
|
cn->payload_variant.key_verification_final.isSender = false;
|
||||||
service->sendClientNotification(cn);
|
service->sendClientNotification(cn);
|
||||||
|
|
||||||
@@ -202,9 +216,9 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
|
|||||||
currentSecurityNumber % 1000);
|
currentSecurityNumber % 1000);
|
||||||
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_inform_tag;
|
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_inform_tag;
|
||||||
cn->payload_variant.key_verification_number_inform.nonce = currentNonce;
|
cn->payload_variant.key_verification_number_inform.nonce = currentNonce;
|
||||||
strncpy(cn->payload_variant.key_verification_number_inform.remote_longname, // should really check for nulls, etc
|
copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_number_inform.remote_longname,
|
||||||
nodeDB->getMeshNode(currentRemoteNode)->user.long_name,
|
sizeof(cn->payload_variant.key_verification_number_inform.remote_longname),
|
||||||
sizeof(cn->payload_variant.key_verification_number_inform.remote_longname));
|
nodeDB->getMeshNode(currentRemoteNode));
|
||||||
cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber;
|
cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber;
|
||||||
service->sendClientNotification(cn);
|
service->sendClientNotification(cn);
|
||||||
LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce);
|
LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce);
|
||||||
@@ -219,7 +233,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
|
|||||||
LOG_WARN("received security number: %u", incomingNumber);
|
LOG_WARN("received security number: %u", incomingNumber);
|
||||||
meshtastic_NodeInfoLite *remoteNodePtr = nullptr;
|
meshtastic_NodeInfoLite *remoteNodePtr = nullptr;
|
||||||
remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
|
remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
|
||||||
if (remoteNodePtr == nullptr || !remoteNodePtr->has_user || remoteNodePtr->user.public_key.size != 32) {
|
if (!remoteNodePtr || !nodeInfoLiteHasUser(remoteNodePtr) || remoteNodePtr->public_key.size != 32) {
|
||||||
currentState = KEY_VERIFICATION_IDLE;
|
currentState = KEY_VERIFICATION_IDLE;
|
||||||
return; // should we throw an error here?
|
return; // should we throw an error here?
|
||||||
}
|
}
|
||||||
@@ -232,7 +246,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
|
|||||||
hash.update(¤tRemoteNode, sizeof(currentRemoteNode));
|
hash.update(¤tRemoteNode, sizeof(currentRemoteNode));
|
||||||
hash.update(owner.public_key.bytes, owner.public_key.size);
|
hash.update(owner.public_key.bytes, owner.public_key.size);
|
||||||
|
|
||||||
hash.update(remoteNodePtr->user.public_key.bytes, remoteNodePtr->user.public_key.size);
|
hash.update(remoteNodePtr->public_key.bytes, remoteNodePtr->public_key.size);
|
||||||
hash.finalize(hash1, 32);
|
hash.finalize(hash1, 32);
|
||||||
|
|
||||||
hash.reset();
|
hash.reset();
|
||||||
@@ -265,9 +279,9 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
|
|||||||
sprintf(cn->message, "Final confirmation for outgoing manual key verification %s", message);
|
sprintf(cn->message, "Final confirmation for outgoing manual key verification %s", message);
|
||||||
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag;
|
cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag;
|
||||||
cn->payload_variant.key_verification_final.nonce = currentNonce;
|
cn->payload_variant.key_verification_final.nonce = currentNonce;
|
||||||
strncpy(cn->payload_variant.key_verification_final.remote_longname, // should really check for nulls, etc
|
copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_final.remote_longname,
|
||||||
nodeDB->getMeshNode(currentRemoteNode)->user.long_name,
|
sizeof(cn->payload_variant.key_verification_final.remote_longname),
|
||||||
sizeof(cn->payload_variant.key_verification_final.remote_longname));
|
nodeDB->getMeshNode(currentRemoteNode));
|
||||||
cn->payload_variant.key_verification_final.isSender = true;
|
cn->payload_variant.key_verification_final.isSender = true;
|
||||||
service->sendClientNotification(cn);
|
service->sendClientNotification(cn);
|
||||||
LOG_INFO(message);
|
LOG_INFO(message);
|
||||||
@@ -310,4 +324,4 @@ void KeyVerificationModule::generateVerificationCode(char *readableCode)
|
|||||||
readableCode[i] = (hash1[i] >> 2) + 48; // not a standardized base64, but workable and avoids having a dictionary.
|
readableCode[i] = (hash1[i] >> 2) + 48; // not a standardized base64, but workable and avoids having a dictionary.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -183,8 +183,7 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket()
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *node = service->refreshLocalMeshNode(); // should guarantee there is now a position
|
const meshtastic_NodeInfoLite *node = service->refreshLocalMeshNode(); // should guarantee there is now a position
|
||||||
assert(node->has_position);
|
|
||||||
|
|
||||||
// configuration of POSITION packet
|
// configuration of POSITION packet
|
||||||
// consider making this a function argument?
|
// consider making this a function argument?
|
||||||
@@ -194,7 +193,9 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket()
|
|||||||
meshtastic_Position p = meshtastic_Position_init_default; // Start with an empty structure
|
meshtastic_Position p = meshtastic_Position_init_default; // Start with an empty structure
|
||||||
// if localPosition is totally empty, put our last saved position (lite) in there
|
// if localPosition is totally empty, put our last saved position (lite) in there
|
||||||
if (localPosition.latitude_i == 0 && localPosition.longitude_i == 0) {
|
if (localPosition.latitude_i == 0 && localPosition.longitude_i == 0) {
|
||||||
nodeDB->setLocalPosition(TypeConversions::ConvertToPosition(node->position));
|
meshtastic_PositionLite cachedSelf;
|
||||||
|
if (nodeDB->copyNodePosition(node->num, cachedSelf))
|
||||||
|
nodeDB->setLocalPosition(TypeConversions::ConvertToPosition(cachedSelf));
|
||||||
}
|
}
|
||||||
localPosition.seq_number++;
|
localPosition.seq_number++;
|
||||||
|
|
||||||
@@ -420,7 +421,7 @@ int32_t PositionModule::runOnce()
|
|||||||
doDeepSleep(nightyNightMs, false, false);
|
doDeepSleep(nightyNightMs, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
if (node == nullptr)
|
if (node == nullptr)
|
||||||
return RUNONCE_INTERVAL;
|
return RUNONCE_INTERVAL;
|
||||||
|
|
||||||
@@ -445,8 +446,11 @@ int32_t PositionModule::runOnce()
|
|||||||
} else if (nodeDB->hasValidPosition(node)) {
|
} else if (nodeDB->hasValidPosition(node)) {
|
||||||
lastGpsSend = now;
|
lastGpsSend = now;
|
||||||
|
|
||||||
lastGpsLatitude = node->position.latitude_i;
|
meshtastic_PositionLite selfPos;
|
||||||
lastGpsLongitude = node->position.longitude_i;
|
if (nodeDB->copyNodePosition(node->num, selfPos)) {
|
||||||
|
lastGpsLatitude = selfPos.latitude_i;
|
||||||
|
lastGpsLongitude = selfPos.longitude_i;
|
||||||
|
}
|
||||||
|
|
||||||
if (transmitHistory)
|
if (transmitHistory)
|
||||||
transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP);
|
transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP);
|
||||||
@@ -461,7 +465,10 @@ int32_t PositionModule::runOnce()
|
|||||||
if (nodeDB->hasValidPosition(node2)) {
|
if (nodeDB->hasValidPosition(node2)) {
|
||||||
// The minimum time (in seconds) that would pass before we are able to send a new position packet.
|
// The minimum time (in seconds) that would pass before we are able to send a new position packet.
|
||||||
|
|
||||||
auto smartPosition = getDistanceTraveledSinceLastSend(node->position);
|
meshtastic_PositionLite selfPos;
|
||||||
|
if (!nodeDB->copyNodePosition(node->num, selfPos))
|
||||||
|
return RUNONCE_INTERVAL; // Defensive: hasValidPosition should imply this is non-null
|
||||||
|
auto smartPosition = getDistanceTraveledSinceLastSend(selfPos);
|
||||||
msSinceLastSend = now - lastGpsSend;
|
msSinceLastSend = now - lastGpsSend;
|
||||||
|
|
||||||
if (smartPosition.hasTraveledOverThreshold &&
|
if (smartPosition.hasTraveledOverThreshold &&
|
||||||
@@ -479,8 +486,8 @@ int32_t PositionModule::runOnce()
|
|||||||
msSinceLastSend, minimumTimeThreshold);
|
msSinceLastSend, minimumTimeThreshold);
|
||||||
|
|
||||||
// Set the current coords as our last ones, after we've compared distance with current and decided to send
|
// Set the current coords as our last ones, after we've compared distance with current and decided to send
|
||||||
lastGpsLatitude = node->position.latitude_i;
|
lastGpsLatitude = selfPos.latitude_i;
|
||||||
lastGpsLongitude = node->position.longitude_i;
|
lastGpsLongitude = selfPos.longitude_i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,11 +570,14 @@ struct SmartPosition PositionModule::getDistanceTraveledSinceLastSend(meshtastic
|
|||||||
|
|
||||||
void PositionModule::handleNewPosition()
|
void PositionModule::handleNewPosition()
|
||||||
{
|
{
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
const meshtastic_NodeInfoLite *node2 = service->refreshLocalMeshNode(); // should guarantee there is now a position
|
const meshtastic_NodeInfoLite *node2 = service->refreshLocalMeshNode(); // should guarantee there is now a position
|
||||||
// We limit our GPS broadcasts to a max rate
|
// We limit our GPS broadcasts to a max rate
|
||||||
if (nodeDB->hasValidPosition(node2)) {
|
if (nodeDB->hasValidPosition(node2)) {
|
||||||
auto smartPosition = getDistanceTraveledSinceLastSend(node->position);
|
meshtastic_PositionLite selfPos;
|
||||||
|
if (!nodeDB->copyNodePosition(node->num, selfPos))
|
||||||
|
return;
|
||||||
|
auto smartPosition = getDistanceTraveledSinceLastSend(selfPos);
|
||||||
uint32_t msSinceLastSend = millis() - lastGpsSend;
|
uint32_t msSinceLastSend = millis() - lastGpsSend;
|
||||||
if (smartPosition.hasTraveledOverThreshold &&
|
if (smartPosition.hasTraveledOverThreshold &&
|
||||||
Throttle::execute(
|
Throttle::execute(
|
||||||
@@ -583,8 +593,8 @@ void PositionModule::handleNewPosition()
|
|||||||
minimumTimeThreshold);
|
minimumTimeThreshold);
|
||||||
|
|
||||||
// Set the current coords as our last ones, after we've compared distance with current and decided to send
|
// Set the current coords as our last ones, after we've compared distance with current and decided to send
|
||||||
lastGpsLatitude = node->position.latitude_i;
|
lastGpsLatitude = selfPos.latitude_i;
|
||||||
lastGpsLongitude = node->position.longitude_i;
|
lastGpsLongitude = selfPos.longitude_i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,26 +268,36 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp)
|
|||||||
fileToAppend.printf("??:??:??,"); // Time
|
fileToAppend.printf("??:??:??,"); // Time
|
||||||
}
|
}
|
||||||
|
|
||||||
fileToAppend.printf("%d,", getFrom(&mp)); // From
|
fileToAppend.printf("%d,", getFrom(&mp)); // From
|
||||||
fileToAppend.printf("%s,", n->user.long_name); // Long Name
|
fileToAppend.printf("%s,", n ? n->long_name : ""); // Long Name
|
||||||
fileToAppend.printf("%f,", n->position.latitude_i * 1e-7); // Sender Lat
|
meshtastic_PositionLite senderPos;
|
||||||
fileToAppend.printf("%f,", n->position.longitude_i * 1e-7); // Sender Long
|
const bool haveSenderPos = nodeDB->copyNodePosition(getFrom(&mp), senderPos);
|
||||||
|
if (haveSenderPos) {
|
||||||
|
fileToAppend.printf("%f,", senderPos.latitude_i * 1e-7); // Sender Lat
|
||||||
|
fileToAppend.printf("%f,", senderPos.longitude_i * 1e-7); // Sender Long
|
||||||
|
} else {
|
||||||
|
fileToAppend.printf("0.0,0.0,");
|
||||||
|
}
|
||||||
if (gpsStatus->getIsConnected() || config.position.fixed_position) {
|
if (gpsStatus->getIsConnected() || config.position.fixed_position) {
|
||||||
fileToAppend.printf("%f,", gpsStatus->getLatitude() * 1e-7); // RX Lat
|
fileToAppend.printf("%f,", gpsStatus->getLatitude() * 1e-7); // RX Lat
|
||||||
fileToAppend.printf("%f,", gpsStatus->getLongitude() * 1e-7); // RX Long
|
fileToAppend.printf("%f,", gpsStatus->getLongitude() * 1e-7); // RX Long
|
||||||
fileToAppend.printf("%d,", gpsStatus->getAltitude()); // RX Altitude
|
fileToAppend.printf("%d,", gpsStatus->getAltitude()); // RX Altitude
|
||||||
} else {
|
} else {
|
||||||
// When the phone API is in use, the node info will be updated with position
|
// When the phone API is in use, the node info will be updated with position
|
||||||
meshtastic_NodeInfoLite *us = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
meshtastic_PositionLite usPos;
|
||||||
fileToAppend.printf("%f,", us->position.latitude_i * 1e-7); // RX Lat
|
if (nodeDB->copyNodePosition(nodeDB->getNodeNum(), usPos)) {
|
||||||
fileToAppend.printf("%f,", us->position.longitude_i * 1e-7); // RX Long
|
fileToAppend.printf("%f,", usPos.latitude_i * 1e-7); // RX Lat
|
||||||
fileToAppend.printf("%d,", us->position.altitude); // RX Altitude
|
fileToAppend.printf("%f,", usPos.longitude_i * 1e-7); // RX Long
|
||||||
|
fileToAppend.printf("%d,", usPos.altitude); // RX Altitude
|
||||||
|
} else {
|
||||||
|
fileToAppend.printf("0.0,0.0,0,");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileToAppend.printf("%f,", mp.rx_snr); // RX SNR
|
fileToAppend.printf("%f,", mp.rx_snr); // RX SNR
|
||||||
|
|
||||||
if (n->position.latitude_i && n->position.longitude_i && gpsStatus->getLatitude() && gpsStatus->getLongitude()) {
|
if (haveSenderPos && senderPos.latitude_i && senderPos.longitude_i && gpsStatus->getLatitude() && gpsStatus->getLongitude()) {
|
||||||
float distance = GeoCoord::latLongToMeter(n->position.latitude_i * 1e-7, n->position.longitude_i * 1e-7,
|
float distance = GeoCoord::latLongToMeter(senderPos.latitude_i * 1e-7, senderPos.longitude_i * 1e-7,
|
||||||
gpsStatus->getLatitude() * 1e-7, gpsStatus->getLongitude() * 1e-7);
|
gpsStatus->getLatitude() * 1e-7, gpsStatus->getLongitude() * 1e-7);
|
||||||
fileToAppend.printf("%f,", distance); // Distance in meters
|
fileToAppend.printf("%f,", distance); // Distance in meters
|
||||||
} else {
|
} else {
|
||||||
@@ -340,4 +350,4 @@ bool RangeTestModuleRadio::removeFile()
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ bool RoutingModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mesh
|
|||||||
config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY)) {
|
config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY)) {
|
||||||
if (!maybePKI)
|
if (!maybePKI)
|
||||||
return false;
|
return false;
|
||||||
if ((nodeDB->getMeshNode(mp.from) == NULL || !nodeDB->getMeshNode(mp.from)->has_user) &&
|
if (!nodeInfoLiteHasUser(nodeDB->getMeshNode(mp.from)) && !nodeInfoLiteHasUser(nodeDB->getMeshNode(mp.to)))
|
||||||
(nodeDB->getMeshNode(mp.to) == NULL || !nodeDB->getMeshNode(mp.to)->has_user))
|
|
||||||
return false;
|
return false;
|
||||||
} else if (owner.is_licensed && ((nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) ||
|
} else if (owner.is_licensed && ((nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) ||
|
||||||
(nodeDB->getLicenseStatus(mp.to) == UserLicenseStatus::NotLicensed))) {
|
(nodeDB->getLicenseStatus(mp.to) == UserLicenseStatus::NotLicensed))) {
|
||||||
|
|||||||
@@ -250,9 +250,12 @@ int32_t SerialModule::runOnce()
|
|||||||
uint32_t readIndex = 0;
|
uint32_t readIndex = 0;
|
||||||
const meshtastic_NodeInfoLite *tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
const meshtastic_NodeInfoLite *tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
||||||
while (tempNodeInfo != NULL) {
|
while (tempNodeInfo != NULL) {
|
||||||
if (tempNodeInfo->has_user && nodeDB->hasValidPosition(tempNodeInfo)) {
|
if (nodeInfoLiteHasUser(tempNodeInfo) && nodeDB->hasValidPosition(tempNodeInfo)) {
|
||||||
printWPL(outbuf, sizeof(outbuf), tempNodeInfo->position, tempNodeInfo->user.long_name, true);
|
meshtastic_PositionLite pos;
|
||||||
serialPrint->printf("%s", outbuf);
|
if (nodeDB->copyNodePosition(tempNodeInfo->num, pos)) {
|
||||||
|
printWPL(outbuf, sizeof(outbuf), pos, tempNodeInfo->long_name, true);
|
||||||
|
serialPrint->printf("%s", outbuf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
||||||
}
|
}
|
||||||
@@ -401,7 +404,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp
|
|||||||
serialPrint->write(p.payload.bytes, p.payload.size);
|
serialPrint->write(p.payload.bytes, p.payload.size);
|
||||||
} else if (moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG) {
|
} else if (moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG) {
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
|
||||||
const char *sender = (node && node->has_user) ? node->user.short_name : "???";
|
const char *sender = nodeInfoLiteHasUser(node) ? node->short_name : "???";
|
||||||
serialPrint->println();
|
serialPrint->println();
|
||||||
serialPrint->printf("%s: %s", sender, p.payload.bytes);
|
serialPrint->printf("%s: %s", sender, p.payload.bytes);
|
||||||
serialPrint->println();
|
serialPrint->println();
|
||||||
@@ -417,9 +420,13 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp
|
|||||||
decoded = &scratch;
|
decoded = &scratch;
|
||||||
}
|
}
|
||||||
// send position packet as WPL to the serial port
|
// send position packet as WPL to the serial port
|
||||||
printWPL(outbuf, sizeof(outbuf), *decoded, nodeDB->getMeshNode(getFrom(&mp))->user.long_name,
|
{
|
||||||
moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO);
|
meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp));
|
||||||
serialPrint->printf("%s", outbuf);
|
const char *senderName = senderNode ? senderNode->long_name : "";
|
||||||
|
printWPL(outbuf, sizeof(outbuf), *decoded, senderName,
|
||||||
|
moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO);
|
||||||
|
serialPrint->printf("%s", outbuf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,4 +721,4 @@ void SerialModule::processWXSerial()
|
|||||||
#endif
|
#endif
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user