mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-22 03:02:08 +10:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add9765404 | ||
|
|
a23f923e64 | ||
|
|
0522039830 | ||
|
|
dfcb685963 | ||
|
|
9bc25b34fd | ||
|
|
33319aa4e2 | ||
|
|
d79e62fd2a | ||
|
|
f6a954b97e | ||
|
|
94bb21ecc7 | ||
|
|
1bcabb893b | ||
|
|
10a7f1042b | ||
|
|
fefd424901 | ||
|
|
b4234b7f11 | ||
|
|
5512185cfe | ||
|
|
a8a785bbb7 | ||
|
|
0f854862e7 | ||
|
|
6fc45e83fc | ||
|
|
85b0e8a7cc | ||
|
|
33db9938b0 | ||
|
|
a2189f43a1 | ||
|
|
847c8055a4 | ||
|
|
3c586c23a3 | ||
|
|
f70234f0f4 | ||
|
|
9721ad8531 | ||
|
|
c19e2f0d90 | ||
|
|
cecd409089 | ||
|
|
4ccbf72c30 | ||
|
|
6951739df5 | ||
|
|
d697eb4e79 | ||
|
|
e65ce4d21c | ||
|
|
b44070e7ef | ||
|
|
93e2c945fe | ||
|
|
2ac35d235b | ||
|
|
bbabd8ffe0 | ||
|
|
adfc95d9ee | ||
|
|
2b56e25a01 | ||
|
|
1d341bb904 | ||
|
|
48af1cd0e1 | ||
|
|
7aa0af30f2 | ||
|
|
1bfeefb957 |
@@ -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,95 @@ 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.
|
||||||
|
|
||||||
|
### Gradient sync (opt-in via special nonces)
|
||||||
|
|
||||||
|
`client_capabilities` is **not** a thing in this branch. Phone clients opt into the new sync flow by sending one of two values in the `ToRadio.want_config_id`:
|
||||||
|
|
||||||
|
- `SPECIAL_NONCE_GRADIENT_SYNC` (69422) — full config + thin NodeInfo + replay phases.
|
||||||
|
- `SPECIAL_NONCE_GRADIENT_ONLY_NODES` (69423) — skip config segments, NodeInfo + replay only.
|
||||||
|
|
||||||
|
`PhoneAPI::clientWantsGradientSync()` is the single switch. When true, `STATE_SEND_OTHER_NODEINFOS` is followed by:
|
||||||
|
|
||||||
|
```text
|
||||||
|
STATE_REPLAY_POSITIONS → STATE_REPLAY_TELEMETRY → STATE_REPLAY_ENVIRONMENT → STATE_REPLAY_STATUS
|
||||||
|
```
|
||||||
|
|
||||||
|
Each replay phase walks the corresponding satellite map and emits synthetic `MeshPacket`s on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` for both device + environment variants, `STATUS_MESSAGE_APP`). Legacy clients (no special nonce) get the bundled-NodeInfo path with position/device_metrics joined back in by `ConvertToNodeInfo(lite, pos*, dm*)` — wire bytes are byte-identical to pre-v25 for them.
|
||||||
|
|
||||||
|
`ConvertToNodeInfoThin(lite)` is the gradient-sync emitter (no position/telemetry).
|
||||||
|
|
||||||
|
### 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 +285,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
|
||||||
|
|
||||||
|
|||||||
+2
-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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -17,14 +18,34 @@ from . import (
|
|||||||
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 +96,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 +108,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 +140,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 +150,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 +781,185 @@ 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,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
-1
Submodule protobufs updated: 1d6f1a71ff...559f3c12dd
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1285,7 +1285,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1670,7 +1670,7 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
|
|||||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from);
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from);
|
||||||
const meshtastic_Channel channel =
|
const meshtastic_Channel channel =
|
||||||
channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex());
|
channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex());
|
||||||
const char *longName = (node && node->has_user) ? node->user.long_name : nullptr;
|
const char *longName = nodeInfoLiteHasUser(node) ? node->long_name : nullptr;
|
||||||
|
|
||||||
const char *msgRaw = reinterpret_cast<const char *>(packet->decoded.payload.bytes);
|
const char *msgRaw = reinterpret_cast<const char *>(packet->decoded.payload.bytes);
|
||||||
|
|
||||||
|
|||||||
@@ -845,10 +845,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 +1355,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 +1376,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 +1386,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 +1409,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 +1426,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 +1457,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 +2075,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,8 +382,11 @@ 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);
|
||||||
|
assert(hasPos);
|
||||||
|
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->has_hops_away, // Is the hopsAway number valid
|
||||||
node->hops_away // Hops away
|
node->hops_away // Hops away
|
||||||
);
|
);
|
||||||
@@ -516,10 +529,13 @@ 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,23 +50,25 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pass to the derived applet
|
// Pass to the derived applet
|
||||||
// Derived applet is responsible for requesting update, if justified
|
// Derived applet is responsible for requesting update, if justified
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,15 +89,17 @@ 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)
|
||||||
cards.push_back(c);
|
cards.push_back(c);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+11
-4
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
+526
-98
@@ -22,6 +22,7 @@
|
|||||||
#include "error.h"
|
#include "error.h"
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
#include "mesh-pb-constants.h"
|
#include "mesh-pb-constants.h"
|
||||||
|
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
|
||||||
#include "meshUtils.h"
|
#include "meshUtils.h"
|
||||||
#include "modules/NeighborInfoModule.h"
|
#include "modules/NeighborInfoModule.h"
|
||||||
#include <ErriezCRC32.h>
|
#include <ErriezCRC32.h>
|
||||||
@@ -151,25 +152,104 @@ uint32_t get_st7789_id(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_
|
|||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_iter_t *field)
|
bool meshtastic_NodeDatabase_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);
|
||||||
|
switch (iter->tag) {
|
||||||
|
case meshtastic_NodeDatabase_nodes_tag: {
|
||||||
if (ostream) {
|
if (ostream) {
|
||||||
std::vector<meshtastic_NodeInfoLite> const *vec = (std::vector<meshtastic_NodeInfoLite> *)field->pData;
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
|
||||||
for (auto item : *vec) {
|
for (auto item : *vec) {
|
||||||
if (!pb_encode_tag_for_field(ostream, field))
|
if (!pb_encode_tag_for_field(ostream, iter))
|
||||||
|
return false;
|
||||||
|
if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_fields, &item))
|
||||||
return false;
|
return false;
|
||||||
pb_encode_submessage(ostream, meshtastic_NodeInfoLite_fields, &item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (istream) {
|
if (istream) {
|
||||||
meshtastic_NodeInfoLite node; // this gets good data
|
meshtastic_NodeInfoLite node;
|
||||||
std::vector<meshtastic_NodeInfoLite> *vec = (std::vector<meshtastic_NodeInfoLite> *)field->pData;
|
auto *vec = static_cast<std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
|
||||||
|
|
||||||
if (istream->bytes_left && pb_decode(istream, meshtastic_NodeInfoLite_fields, &node))
|
if (istream->bytes_left && pb_decode(istream, meshtastic_NodeInfoLite_fields, &node))
|
||||||
vec->push_back(node);
|
vec->push_back(node);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
case meshtastic_NodeDatabase_positions_tag: {
|
||||||
|
if (ostream) {
|
||||||
|
const auto *vec = static_cast<const std::vector<meshtastic_NodePositionEntry> *>(iter->pData);
|
||||||
|
for (auto item : *vec) {
|
||||||
|
if (!pb_encode_tag_for_field(ostream, iter))
|
||||||
|
return false;
|
||||||
|
if (!pb_encode_submessage(ostream, meshtastic_NodePositionEntry_fields, &item))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (istream) {
|
||||||
|
meshtastic_NodePositionEntry entry;
|
||||||
|
auto *vec = static_cast<std::vector<meshtastic_NodePositionEntry> *>(iter->pData);
|
||||||
|
if (istream->bytes_left && pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry))
|
||||||
|
vec->push_back(entry);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case meshtastic_NodeDatabase_telemetry_tag: {
|
||||||
|
if (ostream) {
|
||||||
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeTelemetryEntry> *>(iter->pData);
|
||||||
|
for (auto item : *vec) {
|
||||||
|
if (!pb_encode_tag_for_field(ostream, iter))
|
||||||
|
return false;
|
||||||
|
if (!pb_encode_submessage(ostream, meshtastic_NodeTelemetryEntry_fields, &item))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (istream) {
|
||||||
|
meshtastic_NodeTelemetryEntry entry;
|
||||||
|
auto *vec = static_cast<std::vector<meshtastic_NodeTelemetryEntry> *>(iter->pData);
|
||||||
|
if (istream->bytes_left && pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry))
|
||||||
|
vec->push_back(entry);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case meshtastic_NodeDatabase_status_tag: {
|
||||||
|
if (ostream) {
|
||||||
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeStatusEntry> *>(iter->pData);
|
||||||
|
for (auto item : *vec) {
|
||||||
|
if (!pb_encode_tag_for_field(ostream, iter))
|
||||||
|
return false;
|
||||||
|
if (!pb_encode_submessage(ostream, meshtastic_NodeStatusEntry_fields, &item))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (istream) {
|
||||||
|
meshtastic_NodeStatusEntry entry;
|
||||||
|
auto *vec = static_cast<std::vector<meshtastic_NodeStatusEntry> *>(iter->pData);
|
||||||
|
if (istream->bytes_left && pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry))
|
||||||
|
vec->push_back(entry);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case meshtastic_NodeDatabase_environment_tag: {
|
||||||
|
if (ostream) {
|
||||||
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeEnvironmentEntry> *>(iter->pData);
|
||||||
|
for (auto item : *vec) {
|
||||||
|
if (!pb_encode_tag_for_field(ostream, iter))
|
||||||
|
return false;
|
||||||
|
if (!pb_encode_submessage(ostream, meshtastic_NodeEnvironmentEntry_fields, &item))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (istream) {
|
||||||
|
meshtastic_NodeEnvironmentEntry entry;
|
||||||
|
auto *vec = static_cast<std::vector<meshtastic_NodeEnvironmentEntry> *>(iter->pData);
|
||||||
|
if (istream->bytes_left && pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry))
|
||||||
|
vec->push_back(entry);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The current change # for radio settings. Starts at 0 on boot and any time the radio settings
|
/** The current change # for radio settings. Starts at 0 on boot and any time the radio settings
|
||||||
* might have changed is incremented. Allows others to detect they might now be on a new channel.
|
* might have changed is incremented. Allows others to detect they might now be on a new channel.
|
||||||
@@ -298,8 +378,7 @@ NodeDB::NodeDB()
|
|||||||
#endif
|
#endif
|
||||||
// Include our owner in the node db under our nodenum
|
// Include our owner in the node db under our nodenum
|
||||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
|
||||||
info->user = TypeConversions::ConvertToUserLite(owner);
|
TypeConversions::CopyUserToNodeInfoLite(info, owner);
|
||||||
info->has_user = true;
|
|
||||||
|
|
||||||
// If node database has not been saved for the first time, save it now
|
// If node database has not been saved for the first time, save it now
|
||||||
#ifdef FSCom
|
#ifdef FSCom
|
||||||
@@ -442,8 +521,12 @@ NodeDB::NodeDB()
|
|||||||
#if defined(USERPREFS_FIXED_GPS_LAT) && defined(USERPREFS_FIXED_GPS_LON)
|
#if defined(USERPREFS_FIXED_GPS_LAT) && defined(USERPREFS_FIXED_GPS_LON)
|
||||||
fixedGPS.location_source = meshtastic_Position_LocSource_LOC_MANUAL;
|
fixedGPS.location_source = meshtastic_Position_LocSource_LOC_MANUAL;
|
||||||
config.has_position = true;
|
config.has_position = true;
|
||||||
info->has_position = true;
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
info->position = TypeConversions::ConvertToPositionLite(fixedGPS);
|
{
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
nodePositions[info->num] = TypeConversions::ConvertToPositionLite(fixedGPS);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
nodeDB->setLocalPosition(fixedGPS);
|
nodeDB->setLocalPosition(fixedGPS);
|
||||||
config.position.fixed_position = true;
|
config.position.fixed_position = true;
|
||||||
#endif
|
#endif
|
||||||
@@ -479,6 +562,29 @@ bool isBroadcast(uint32_t dest)
|
|||||||
return dest == NODENUM_BROADCAST || dest == NODENUM_BROADCAST_NO_LORA;
|
return dest == NODENUM_BROADCAST || dest == NODENUM_BROADCAST_NO_LORA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
template <typename Map, typename Value> bool copySatelliteEntry(const Map &map, NodeNum n, Value &out)
|
||||||
|
{
|
||||||
|
auto it = map.find(n);
|
||||||
|
if (it == map.end())
|
||||||
|
return false;
|
||||||
|
out = it->second;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Map> std::vector<NodeNum> snapshotSatelliteNodeNums(const Map &map, NodeNum exclude)
|
||||||
|
{
|
||||||
|
std::vector<NodeNum> result;
|
||||||
|
result.reserve(map.size());
|
||||||
|
for (const auto &kv : map) {
|
||||||
|
if (kv.first != exclude)
|
||||||
|
result.push_back(kv.first);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void NodeDB::resetRadioConfig(bool is_fresh_install)
|
void NodeDB::resetRadioConfig(bool is_fresh_install)
|
||||||
{
|
{
|
||||||
if (is_fresh_install) {
|
if (is_fresh_install) {
|
||||||
@@ -548,6 +654,19 @@ void NodeDB::installDefaultNodeDatabase()
|
|||||||
nodeDatabase.nodes = std::vector<meshtastic_NodeInfoLite>(MAX_NUM_NODES);
|
nodeDatabase.nodes = std::vector<meshtastic_NodeInfoLite>(MAX_NUM_NODES);
|
||||||
numMeshNodes = 0;
|
numMeshNodes = 0;
|
||||||
meshNodes = &nodeDatabase.nodes;
|
meshNodes = &nodeDatabase.nodes;
|
||||||
|
concurrency::LockGuard satelliteGuard(&satelliteMutex);
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
nodePositions.clear();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
nodeTelemetry.clear();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
nodeEnvironment.clear();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
nodeStatus.clear();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void NodeDB::installDefaultConfig(bool preserveKey = false)
|
void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||||
@@ -1019,12 +1138,14 @@ void NodeDB::resetNodes(bool keepFavorites)
|
|||||||
{
|
{
|
||||||
if (!config.position.fixed_position)
|
if (!config.position.fixed_position)
|
||||||
clearLocalPosition();
|
clearLocalPosition();
|
||||||
|
NodeNum ourNum = getNodeNum();
|
||||||
numMeshNodes = 1;
|
numMeshNodes = 1;
|
||||||
if (keepFavorites) {
|
if (keepFavorites) {
|
||||||
LOG_INFO("Clearing node database - preserving favorites");
|
LOG_INFO("Clearing node database - preserving favorites");
|
||||||
for (size_t i = 0; i < meshNodes->size(); i++) {
|
for (size_t i = 0; i < meshNodes->size(); i++) {
|
||||||
meshtastic_NodeInfoLite &node = meshNodes->at(i);
|
meshtastic_NodeInfoLite &node = meshNodes->at(i);
|
||||||
if (i > 0 && !node.is_favorite) {
|
if (i > 0 && !nodeInfoLiteIsFavorite(&node)) {
|
||||||
|
eraseNodeSatellites(node.num);
|
||||||
node = meshtastic_NodeInfoLite();
|
node = meshtastic_NodeInfoLite();
|
||||||
} else {
|
} else {
|
||||||
numMeshNodes += 1;
|
numMeshNodes += 1;
|
||||||
@@ -1032,8 +1153,14 @@ void NodeDB::resetNodes(bool keepFavorites)
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
LOG_INFO("Clearing node database - removing favorites");
|
LOG_INFO("Clearing node database - removing favorites");
|
||||||
|
for (size_t i = 1; i < meshNodes->size(); i++) {
|
||||||
|
const NodeNum gone = meshNodes->at(i).num;
|
||||||
|
if (gone)
|
||||||
|
eraseNodeSatellites(gone);
|
||||||
|
}
|
||||||
std::fill(nodeDatabase.nodes.begin() + 1, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite());
|
std::fill(nodeDatabase.nodes.begin() + 1, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite());
|
||||||
}
|
}
|
||||||
|
(void)ourNum;
|
||||||
devicestate.has_rx_text_message = false;
|
devicestate.has_rx_text_message = false;
|
||||||
devicestate.has_rx_waypoint = false;
|
devicestate.has_rx_waypoint = false;
|
||||||
saveNodeDatabaseToDisk();
|
saveNodeDatabaseToDisk();
|
||||||
@@ -1054,36 +1181,173 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
|
|||||||
numMeshNodes -= removed;
|
numMeshNodes -= removed;
|
||||||
std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.begin() + numMeshNodes + 1,
|
std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.begin() + numMeshNodes + 1,
|
||||||
meshtastic_NodeInfoLite());
|
meshtastic_NodeInfoLite());
|
||||||
|
if (removed)
|
||||||
|
eraseNodeSatellites(nodeNum);
|
||||||
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
|
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
|
||||||
saveNodeDatabaseToDisk();
|
saveNodeDatabaseToDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
void NodeDB::clearLocalPosition()
|
void NodeDB::clearLocalPosition()
|
||||||
{
|
{
|
||||||
meshtastic_NodeInfoLite *node = getMeshNode(nodeDB->getNodeNum());
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
node->position.latitude_i = 0;
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
node->position.longitude_i = 0;
|
nodePositions.erase(getNodeNum());
|
||||||
node->position.altitude = 0;
|
#endif
|
||||||
node->position.time = 0;
|
|
||||||
setLocalPosition(meshtastic_Position_init_default);
|
setLocalPosition(meshtastic_Position_init_default);
|
||||||
localPositionUpdatedSinceBoot = false;
|
localPositionUpdatedSinceBoot = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool NodeDB::copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
(void)n;
|
||||||
|
(void)out;
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return copySatelliteEntry(nodePositions, n, out);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NodeDB::copyNodeTelemetry(NodeNum n, meshtastic_DeviceMetrics &out) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
(void)n;
|
||||||
|
(void)out;
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return copySatelliteEntry(nodeTelemetry, n, out);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NodeDB::copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
(void)n;
|
||||||
|
(void)out;
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return copySatelliteEntry(nodeEnvironment, n, out);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NodeDB::copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
(void)n;
|
||||||
|
(void)out;
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return copySatelliteEntry(nodeStatus, n, out);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<NodeNum> NodeDB::snapshotPositionNodeNums(NodeNum exclude) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
(void)exclude;
|
||||||
|
return {};
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return snapshotSatelliteNodeNums(nodePositions, exclude);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<NodeNum> NodeDB::snapshotTelemetryNodeNums(NodeNum exclude) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
(void)exclude;
|
||||||
|
return {};
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return snapshotSatelliteNodeNums(nodeTelemetry, exclude);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<NodeNum> NodeDB::snapshotEnvironmentNodeNums(NodeNum exclude) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
(void)exclude;
|
||||||
|
return {};
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return snapshotSatelliteNodeNums(nodeEnvironment, exclude);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<NodeNum> NodeDB::snapshotStatusNodeNums(NodeNum exclude) const
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
(void)exclude;
|
||||||
|
return {};
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
return snapshotSatelliteNodeNums(nodeStatus, exclude);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodeDB::setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status)
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
(void)n;
|
||||||
|
(void)status;
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
nodeStatus[n] = status;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodeDB::touchNodePositionTime(NodeNum n, uint32_t time)
|
||||||
|
{
|
||||||
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
(void)n;
|
||||||
|
(void)time;
|
||||||
|
#else
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
nodePositions[n].time = time;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodeDB::eraseNodeSatellites(NodeNum n)
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
nodePositions.erase(n);
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
nodeTelemetry.erase(n);
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
nodeEnvironment.erase(n);
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
nodeStatus.erase(n);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
void NodeDB::cleanupMeshDB()
|
void NodeDB::cleanupMeshDB()
|
||||||
{
|
{
|
||||||
int newPos = 0, removed = 0;
|
int newPos = 0, removed = 0;
|
||||||
for (int i = 0; i < numMeshNodes; i++) {
|
for (int i = 0; i < numMeshNodes; i++) {
|
||||||
if (meshNodes->at(i).has_user) {
|
meshtastic_NodeInfoLite &n = meshNodes->at(i);
|
||||||
if (meshNodes->at(i).user.public_key.size > 0) {
|
if (nodeInfoLiteHasUser(&n)) {
|
||||||
if (memfll(meshNodes->at(i).user.public_key.bytes, 0, meshNodes->at(i).user.public_key.size)) {
|
if (n.public_key.size > 0) {
|
||||||
meshNodes->at(i).user.public_key.size = 0;
|
if (memfll(n.public_key.bytes, 0, n.public_key.size)) {
|
||||||
|
n.public_key.size = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newPos != i)
|
if (newPos != i)
|
||||||
meshNodes->at(newPos++) = meshNodes->at(i);
|
meshNodes->at(newPos++) = n;
|
||||||
else
|
else
|
||||||
newPos++;
|
newPos++;
|
||||||
} else {
|
} else {
|
||||||
|
// No user info - drop this node and its satellites
|
||||||
|
const NodeNum gone = n.num;
|
||||||
|
if (gone)
|
||||||
|
eraseNodeSatellites(gone);
|
||||||
removed++;
|
removed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1141,14 +1405,22 @@ void NodeDB::pickNewNodeNum()
|
|||||||
nodeNum = (ourMacAddr[2] << 24) | (ourMacAddr[3] << 16) | (ourMacAddr[4] << 8) | ourMacAddr[5];
|
nodeNum = (ourMacAddr[2] << 24) | (ourMacAddr[3] << 16) | (ourMacAddr[4] << 8) | ourMacAddr[5];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identity check via public key (or "empty slot?" when no keys yet);
|
||||||
|
// macaddr no longer lives on the slim header.
|
||||||
|
auto isOurOwnEntry = [&](const meshtastic_NodeInfoLite *n) -> bool {
|
||||||
|
if (!n)
|
||||||
|
return false;
|
||||||
|
if (owner.public_key.size == 32 && n->public_key.size == 32)
|
||||||
|
return memcmp(n->public_key.bytes, owner.public_key.bytes, 32) == 0;
|
||||||
|
return !nodeInfoLiteHasUser(n);
|
||||||
|
};
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *found;
|
meshtastic_NodeInfoLite *found;
|
||||||
while (((found = getMeshNode(nodeNum)) && memcmp(found->user.macaddr, ourMacAddr, sizeof(ourMacAddr)) != 0) ||
|
while (((found = getMeshNode(nodeNum)) && !isOurOwnEntry(found)) ||
|
||||||
(nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) {
|
(nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) {
|
||||||
NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice
|
NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice
|
||||||
if (found)
|
if (found)
|
||||||
LOG_WARN("NOTE! Our desired nodenum 0x%x is invalid or in use, by MAC ending in 0x%02x%02x vs our 0x%02x%02x, so "
|
LOG_WARN("NOTE! Our desired nodenum 0x%x is invalid or in use, picking 0x%x", nodeNum, candidate);
|
||||||
"trying for 0x%x",
|
|
||||||
nodeNum, found->user.macaddr[4], found->user.macaddr[5], ourMacAddr[4], ourMacAddr[5], candidate);
|
|
||||||
nodeNum = candidate;
|
nodeNum = candidate;
|
||||||
}
|
}
|
||||||
LOG_DEBUG("Use nodenum 0x%x ", nodeNum);
|
LOG_DEBUG("Use nodenum 0x%x ", nodeNum);
|
||||||
@@ -1169,7 +1441,8 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t
|
|||||||
if (f) {
|
if (f) {
|
||||||
LOG_INFO("Load %s", filename);
|
LOG_INFO("Load %s", filename);
|
||||||
pb_istream_t stream = {&readcb, &f, protoSize};
|
pb_istream_t stream = {&readcb, &f, protoSize};
|
||||||
if (fields != &meshtastic_NodeDatabase_msg) // contains a vector object
|
if (fields != &meshtastic_NodeDatabase_msg &&
|
||||||
|
fields != &meshtastic_NodeDatabase_Legacy_msg) // both NodeDatabase descriptors contain std::vector members
|
||||||
memset(dest_struct, 0, objSize);
|
memset(dest_struct, 0, objSize);
|
||||||
if (!pb_decode(&stream, fields, dest_struct)) {
|
if (!pb_decode(&stream, fields, dest_struct)) {
|
||||||
LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream));
|
LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream));
|
||||||
@@ -1242,9 +1515,57 @@ void NodeDB::loadFromDisk()
|
|||||||
if (nodeDatabase.version < DEVICESTATE_MIN_VER) {
|
if (nodeDatabase.version < DEVICESTATE_MIN_VER) {
|
||||||
LOG_WARN("NodeDatabase %d is old, discard", nodeDatabase.version);
|
LOG_WARN("NodeDatabase %d is old, discard", nodeDatabase.version);
|
||||||
installDefaultNodeDatabase();
|
installDefaultNodeDatabase();
|
||||||
|
} else if (nodeDatabase.version < DEVICESTATE_CUR_VER) {
|
||||||
|
if (migrateLegacyNodeDatabase())
|
||||||
|
saveNodeDatabaseToDisk();
|
||||||
|
else
|
||||||
|
installDefaultNodeDatabase();
|
||||||
} else {
|
} else {
|
||||||
meshNodes = &nodeDatabase.nodes;
|
meshNodes = &nodeDatabase.nodes;
|
||||||
numMeshNodes = nodeDatabase.nodes.size();
|
numMeshNodes = nodeDatabase.nodes.size();
|
||||||
|
// Hydrate the satellite maps; the on-disk vectors stay empty in steady
|
||||||
|
// state and are repopulated only at save time.
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
nodePositions.clear();
|
||||||
|
nodePositions.reserve(nodeDatabase.positions.size());
|
||||||
|
for (const auto &entry : nodeDatabase.positions) {
|
||||||
|
if (entry.has_position)
|
||||||
|
nodePositions[entry.num] = entry.position;
|
||||||
|
}
|
||||||
|
nodeDatabase.positions.clear();
|
||||||
|
nodeDatabase.positions.shrink_to_fit();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
nodeTelemetry.clear();
|
||||||
|
nodeTelemetry.reserve(nodeDatabase.telemetry.size());
|
||||||
|
for (const auto &entry : nodeDatabase.telemetry) {
|
||||||
|
if (entry.has_device_metrics)
|
||||||
|
nodeTelemetry[entry.num] = entry.device_metrics;
|
||||||
|
}
|
||||||
|
nodeDatabase.telemetry.clear();
|
||||||
|
nodeDatabase.telemetry.shrink_to_fit();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
nodeEnvironment.clear();
|
||||||
|
nodeEnvironment.reserve(nodeDatabase.environment.size());
|
||||||
|
for (const auto &entry : nodeDatabase.environment) {
|
||||||
|
if (entry.has_environment_metrics)
|
||||||
|
nodeEnvironment[entry.num] = entry.environment_metrics;
|
||||||
|
}
|
||||||
|
nodeDatabase.environment.clear();
|
||||||
|
nodeDatabase.environment.shrink_to_fit();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
nodeStatus.clear();
|
||||||
|
nodeStatus.reserve(nodeDatabase.status.size());
|
||||||
|
for (const auto &entry : nodeDatabase.status) {
|
||||||
|
if (entry.has_status)
|
||||||
|
nodeStatus[entry.num] = entry.status;
|
||||||
|
}
|
||||||
|
nodeDatabase.status.clear();
|
||||||
|
nodeDatabase.status.shrink_to_fit();
|
||||||
|
#endif
|
||||||
LOG_INFO("Loaded saved nodedatabase version %d, with nodes count: %d", nodeDatabase.version, nodeDatabase.nodes.size());
|
LOG_INFO("Loaded saved nodedatabase version %d, with nodes count: %d", nodeDatabase.version, nodeDatabase.nodes.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,16 +1593,16 @@ void NodeDB::loadFromDisk()
|
|||||||
|
|
||||||
// Attempt recovery of owner fields from our own NodeDB entry if available.
|
// Attempt recovery of owner fields from our own NodeDB entry if available.
|
||||||
meshtastic_NodeInfoLite *us = getMeshNode(getNodeNum());
|
meshtastic_NodeInfoLite *us = getMeshNode(getNodeNum());
|
||||||
if (us && us->has_user) {
|
if (nodeInfoLiteHasUser(us)) {
|
||||||
LOG_WARN("Restoring owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for our node 0x%08x",
|
LOG_WARN("Restoring owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for our node 0x%08x",
|
||||||
us->num);
|
us->num);
|
||||||
memcpy(owner.long_name, us->user.long_name, sizeof(owner.long_name));
|
memcpy(owner.long_name, us->long_name, sizeof(owner.long_name));
|
||||||
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
|
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
|
||||||
memcpy(owner.short_name, us->user.short_name, sizeof(owner.short_name));
|
memcpy(owner.short_name, us->short_name, sizeof(owner.short_name));
|
||||||
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
|
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
|
||||||
owner.is_licensed = us->user.is_licensed;
|
owner.is_licensed = nodeInfoLiteIsLicensed(us);
|
||||||
owner.has_is_unmessagable = us->user.has_is_unmessagable;
|
owner.has_is_unmessagable = nodeInfoLiteHasIsUnmessagable(us);
|
||||||
owner.is_unmessagable = us->user.is_unmessagable;
|
owner.is_unmessagable = nodeInfoLiteIsUnmessagable(us);
|
||||||
|
|
||||||
// Save the recovered owner to device state on disk
|
// Save the recovered owner to device state on disk
|
||||||
saveToDisk(SEGMENT_DEVICESTATE);
|
saveToDisk(SEGMENT_DEVICESTATE);
|
||||||
@@ -1528,9 +1849,75 @@ bool NodeDB::saveNodeDatabaseToDisk()
|
|||||||
FSCom.mkdir("/prefs");
|
FSCom.mkdir("/prefs");
|
||||||
spiLock->unlock();
|
spiLock->unlock();
|
||||||
#endif
|
#endif
|
||||||
|
// Project the maps into the on-disk vectors just before encoding; cleared
|
||||||
|
// again on the way out so we don't carry duplicate state.
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
nodeDatabase.positions.clear();
|
||||||
|
nodeDatabase.positions.reserve(nodePositions.size());
|
||||||
|
for (const auto &kv : nodePositions) {
|
||||||
|
meshtastic_NodePositionEntry entry = meshtastic_NodePositionEntry_init_default;
|
||||||
|
entry.num = kv.first;
|
||||||
|
entry.has_position = true;
|
||||||
|
entry.position = kv.second;
|
||||||
|
nodeDatabase.positions.push_back(entry);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
nodeDatabase.positions.clear();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
nodeDatabase.telemetry.clear();
|
||||||
|
nodeDatabase.telemetry.reserve(nodeTelemetry.size());
|
||||||
|
for (const auto &kv : nodeTelemetry) {
|
||||||
|
meshtastic_NodeTelemetryEntry entry = meshtastic_NodeTelemetryEntry_init_default;
|
||||||
|
entry.num = kv.first;
|
||||||
|
entry.has_device_metrics = true;
|
||||||
|
entry.device_metrics = kv.second;
|
||||||
|
nodeDatabase.telemetry.push_back(entry);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
nodeDatabase.telemetry.clear();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
nodeDatabase.environment.clear();
|
||||||
|
nodeDatabase.environment.reserve(nodeEnvironment.size());
|
||||||
|
for (const auto &kv : nodeEnvironment) {
|
||||||
|
meshtastic_NodeEnvironmentEntry entry = meshtastic_NodeEnvironmentEntry_init_default;
|
||||||
|
entry.num = kv.first;
|
||||||
|
entry.has_environment_metrics = true;
|
||||||
|
entry.environment_metrics = kv.second;
|
||||||
|
nodeDatabase.environment.push_back(entry);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
nodeDatabase.environment.clear();
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
nodeDatabase.status.clear();
|
||||||
|
nodeDatabase.status.reserve(nodeStatus.size());
|
||||||
|
for (const auto &kv : nodeStatus) {
|
||||||
|
meshtastic_NodeStatusEntry entry = meshtastic_NodeStatusEntry_init_default;
|
||||||
|
entry.num = kv.first;
|
||||||
|
entry.has_status = true;
|
||||||
|
entry.status = kv.second;
|
||||||
|
nodeDatabase.status.push_back(entry);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
nodeDatabase.status.clear();
|
||||||
|
#endif
|
||||||
|
|
||||||
size_t nodeDatabaseSize;
|
size_t nodeDatabaseSize;
|
||||||
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &nodeDatabase);
|
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &nodeDatabase);
|
||||||
return saveProto(nodeDatabaseFileName, nodeDatabaseSize, &meshtastic_NodeDatabase_msg, &nodeDatabase, false);
|
bool ok = saveProto(nodeDatabaseFileName, nodeDatabaseSize, &meshtastic_NodeDatabase_msg, &nodeDatabase, false);
|
||||||
|
|
||||||
|
nodeDatabase.positions.clear();
|
||||||
|
nodeDatabase.positions.shrink_to_fit();
|
||||||
|
nodeDatabase.telemetry.clear();
|
||||||
|
nodeDatabase.telemetry.shrink_to_fit();
|
||||||
|
nodeDatabase.environment.clear();
|
||||||
|
nodeDatabase.environment.shrink_to_fit();
|
||||||
|
nodeDatabase.status.clear();
|
||||||
|
nodeDatabase.status.shrink_to_fit();
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool NodeDB::saveToDiskNoRetry(int saveWhat)
|
bool NodeDB::saveToDiskNoRetry(int saveWhat)
|
||||||
@@ -1699,7 +2086,7 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
|
|||||||
|
|
||||||
// FIXME this implementation is kinda expensive
|
// FIXME this implementation is kinda expensive
|
||||||
for (int i = 0; i < numMeshNodes; i++) {
|
for (int i = 0; i < numMeshNodes; i++) {
|
||||||
if (localOnly && meshNodes->at(i).via_mqtt)
|
if (localOnly && nodeInfoLiteViaMqtt(&meshNodes->at(i)))
|
||||||
continue;
|
continue;
|
||||||
if (sinceLastSeen(&meshNodes->at(i)) < NUM_ONLINE_SECS)
|
if (sinceLastSeen(&meshNodes->at(i)) < NUM_ONLINE_SECS)
|
||||||
numseen++;
|
numseen++;
|
||||||
@@ -1736,18 +2123,34 @@ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSou
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
// Build flag opted out: header still tracks last_heard via updateFrom; we
|
||||||
|
// simply don't cache the position payload anywhere on this device.
|
||||||
|
if (src == RX_SRC_LOCAL) {
|
||||||
|
LOG_INFO("updatePosition LOCAL (PositionDB excluded) time=%u lat=%d lon=%d alt=%d", p.time, p.latitude_i, p.longitude_i,
|
||||||
|
p.altitude);
|
||||||
|
setLocalPosition(p);
|
||||||
|
}
|
||||||
|
(void)nodeId;
|
||||||
|
updateGUIforNode = info;
|
||||||
|
notifyObservers(true);
|
||||||
|
#else
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
meshtastic_PositionLite &slot = nodePositions[nodeId]; // creates default-zero entry if missing
|
||||||
|
|
||||||
if (src == RX_SRC_LOCAL) {
|
if (src == RX_SRC_LOCAL) {
|
||||||
// Local packet, fully authoritative
|
// Local packet, fully authoritative
|
||||||
LOG_INFO("updatePosition LOCAL pos@%x time=%u lat=%d lon=%d alt=%d", p.timestamp, p.time, p.latitude_i, p.longitude_i,
|
LOG_INFO("updatePosition LOCAL pos@%x time=%u lat=%d lon=%d alt=%d", p.timestamp, p.time, p.latitude_i, p.longitude_i,
|
||||||
p.altitude);
|
p.altitude);
|
||||||
|
|
||||||
setLocalPosition(p);
|
setLocalPosition(p);
|
||||||
info->position = TypeConversions::ConvertToPositionLite(p);
|
slot = TypeConversions::ConvertToPositionLite(p);
|
||||||
} else if ((p.time > 0) && !p.latitude_i && !p.longitude_i && !p.timestamp && !p.location_source) {
|
} else if ((p.time > 0) && !p.latitude_i && !p.longitude_i && !p.timestamp && !p.location_source) {
|
||||||
// FIXME SPECIAL TIME SETTING PACKET FROM EUD TO RADIO
|
// FIXME SPECIAL TIME SETTING PACKET FROM EUD TO RADIO
|
||||||
// (stop-gap fix for issue #900)
|
// (stop-gap fix for issue #900)
|
||||||
LOG_DEBUG("updatePosition SPECIAL time setting time=%u", p.time);
|
LOG_DEBUG("updatePosition SPECIAL time setting time=%u", p.time);
|
||||||
info->position.time = p.time;
|
slot.time = p.time;
|
||||||
} else {
|
} else {
|
||||||
// Be careful to only update fields that have been set by the REMOTE sender
|
// Be careful to only update fields that have been set by the REMOTE sender
|
||||||
// A lot of position reports don't have time populated. In that case, be careful to not blow away the time we
|
// A lot of position reports don't have time populated. In that case, be careful to not blow away the time we
|
||||||
@@ -1757,41 +2160,57 @@ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSou
|
|||||||
LOG_INFO("updatePosition REMOTE node=0x%x time=%u lat=%d lon=%d", nodeId, p.time, p.latitude_i, p.longitude_i);
|
LOG_INFO("updatePosition REMOTE node=0x%x time=%u lat=%d lon=%d", nodeId, p.time, p.latitude_i, p.longitude_i);
|
||||||
|
|
||||||
// First, back up fields that we want to protect from overwrite
|
// First, back up fields that we want to protect from overwrite
|
||||||
uint32_t tmp_time = info->position.time;
|
uint32_t tmp_time = slot.time;
|
||||||
|
|
||||||
// Next, update atomically
|
// Next, update atomically
|
||||||
info->position = TypeConversions::ConvertToPositionLite(p);
|
slot = TypeConversions::ConvertToPositionLite(p);
|
||||||
|
|
||||||
// Last, restore any fields that may have been overwritten
|
// Last, restore any fields that may have been overwritten
|
||||||
if (!info->position.time)
|
if (!slot.time)
|
||||||
info->position.time = tmp_time;
|
slot.time = tmp_time;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
info->has_position = true;
|
|
||||||
updateGUIforNode = info;
|
updateGUIforNode = info;
|
||||||
notifyObservers(true); // Force an update whether or not our node counts have changed
|
notifyObservers(true); // Force an update whether or not our node counts have changed
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update telemetry info for this node based on received metrics
|
/** Update telemetry info for this node based on received metrics. Stores
|
||||||
* We only care about device telemetry here
|
* device_metrics and environment_metrics into their respective satellite
|
||||||
|
* maps; other variants (air_quality, power, local_stats, health) are
|
||||||
|
* intentionally not retained per-node.
|
||||||
*/
|
*/
|
||||||
void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src)
|
void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src)
|
||||||
{
|
{
|
||||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
||||||
// Environment metrics should never go to NodeDb but we'll safegaurd anyway
|
if (!info)
|
||||||
if (!info || t.which_variant != meshtastic_Telemetry_device_metrics_tag) {
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
|
if (t.which_variant == meshtastic_Telemetry_device_metrics_tag) {
|
||||||
if (src == RX_SRC_LOCAL) {
|
if (src == RX_SRC_LOCAL) {
|
||||||
// Local packet, fully authoritative
|
LOG_DEBUG("updateTelemetry LOCAL device");
|
||||||
LOG_DEBUG("updateTelemetry LOCAL");
|
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG("updateTelemetry REMOTE node=0x%x ", nodeId);
|
LOG_DEBUG("updateTelemetry REMOTE device node=0x%x", nodeId);
|
||||||
|
}
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
nodeTelemetry[nodeId] = t.variant.device_metrics;
|
||||||
|
#endif
|
||||||
|
} else if (t.which_variant == meshtastic_Telemetry_environment_metrics_tag) {
|
||||||
|
if (src == RX_SRC_LOCAL) {
|
||||||
|
LOG_DEBUG("updateTelemetry LOCAL env");
|
||||||
|
} else {
|
||||||
|
LOG_DEBUG("updateTelemetry REMOTE env node=0x%x", nodeId);
|
||||||
|
}
|
||||||
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
concurrency::LockGuard guard(&satelliteMutex);
|
||||||
|
nodeEnvironment[nodeId] = t.variant.environment_metrics;
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
return; // air_quality / power / local_stats / health: not stored per-node
|
||||||
}
|
}
|
||||||
info->device_metrics = t.variant.device_metrics;
|
|
||||||
info->has_device_metrics = true;
|
|
||||||
updateGUIforNode = info;
|
updateGUIforNode = info;
|
||||||
notifyObservers(true); // Force an update whether or not our node counts have changed
|
notifyObservers(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1806,24 +2225,22 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
|||||||
// If the local node has this node marked as manually verified
|
// If the local node has this node marked as manually verified
|
||||||
// and the client does not, do not allow the client to update the
|
// and the client does not, do not allow the client to update the
|
||||||
// saved public key.
|
// saved public key.
|
||||||
if ((info->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK) && !contact.manually_verified) {
|
if (nodeInfoLiteIsKeyManuallyVerified(info) && !contact.manually_verified) {
|
||||||
if (contact.user.public_key.size != info->user.public_key.size ||
|
if (contact.user.public_key.size != info->public_key.size ||
|
||||||
memcmp(contact.user.public_key.bytes, info->user.public_key.bytes, info->user.public_key.size) != 0) {
|
memcmp(contact.user.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info->num = contact.node_num;
|
info->num = contact.node_num;
|
||||||
info->has_user = true;
|
TypeConversions::CopyUserToNodeInfoLite(info, contact.user);
|
||||||
info->user = TypeConversions::ConvertToUserLite(contact.user);
|
|
||||||
if (contact.should_ignore) {
|
if (contact.should_ignore) {
|
||||||
// If should_ignore is set,
|
// If should_ignore is set,
|
||||||
// we need to clear the public key and other cruft, in addition to setting the node as ignored
|
// we need to clear the public key and other cruft, in addition to setting the node as ignored
|
||||||
info->is_ignored = true;
|
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||||
info->is_favorite = false;
|
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false);
|
||||||
info->has_device_metrics = false;
|
eraseNodeSatellites(contact.node_num);
|
||||||
info->has_position = false;
|
info->public_key.size = 0;
|
||||||
info->user.public_key.size = 0;
|
memset(info->public_key.bytes, 0, sizeof(info->public_key.bytes));
|
||||||
memset(info->user.public_key.bytes, 0, sizeof(info->user.public_key.bytes));
|
|
||||||
} else {
|
} else {
|
||||||
/* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with
|
/* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with
|
||||||
* public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM!
|
* public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM!
|
||||||
@@ -1842,12 +2259,12 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
|||||||
} else {
|
} else {
|
||||||
// Normal case: set is_favorite to prevent expiration.
|
// Normal case: set is_favorite to prevent expiration.
|
||||||
// last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB).
|
// last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB).
|
||||||
info->is_favorite = true;
|
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified
|
// As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified
|
||||||
if (contact.manually_verified) {
|
if (contact.manually_verified) {
|
||||||
info->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
|
||||||
}
|
}
|
||||||
// Mark the node's key as manually verified to indicate trustworthiness.
|
// Mark the node's key as manually verified to indicate trustworthiness.
|
||||||
updateGUIforNode = info;
|
updateGUIforNode = info;
|
||||||
@@ -1881,15 +2298,15 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
|
|||||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||||
cn->level = meshtastic_LogRecord_Level_WARNING;
|
cn->level = meshtastic_LogRecord_Level_WARNING;
|
||||||
cn->time = getValidTime(RTCQualityFromNet);
|
cn->time = getValidTime(RTCQualityFromNet);
|
||||||
sprintf(cn->message, warning, p.long_name);
|
snprintf(cn->message, sizeof(cn->message), warning, p.long_name);
|
||||||
service->sendClientNotification(cn);
|
service->sendClientNotification(cn);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (info->user.public_key.size == 32) { // if we have a key for this user already, don't overwrite with a new one
|
if (info->public_key.size == 32) { // if we have a key for this user already, don't overwrite with a new one
|
||||||
// if the key doesn't match, don't update nodeDB at all.
|
// if the key doesn't match, don't update nodeDB at all.
|
||||||
if (p.public_key.size != 32 || (memcmp(p.public_key.bytes, info->user.public_key.bytes, 32) != 0)) {
|
if (p.public_key.size != 32 || (memcmp(p.public_key.bytes, info->public_key.bytes, 32) != 0)) {
|
||||||
LOG_WARN("Public Key mismatch, dropping NodeInfo");
|
LOG_WARN("Public Key mismatch, dropping NodeInfo");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1902,19 +2319,22 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
|
|||||||
// Always ensure user.id is derived from nodeId, regardless of what was received
|
// Always ensure user.id is derived from nodeId, regardless of what was received
|
||||||
snprintf(p.id, sizeof(p.id), "!%08x", nodeId);
|
snprintf(p.id, sizeof(p.id), "!%08x", nodeId);
|
||||||
|
|
||||||
// Both of info->user and p start as filled with zero so I think this is okay
|
meshtastic_NodeInfoLite before = *info;
|
||||||
auto lite = TypeConversions::ConvertToUserLite(p);
|
TypeConversions::CopyUserToNodeInfoLite(info, p);
|
||||||
bool changed = memcmp(&info->user, &lite, sizeof(info->user)) || (info->channel != channelIndex);
|
bool changed =
|
||||||
|
(memcmp(before.long_name, info->long_name, sizeof(info->long_name)) != 0) ||
|
||||||
|
(memcmp(before.short_name, info->short_name, sizeof(info->short_name)) != 0) || (before.hw_model != info->hw_model) ||
|
||||||
|
(before.role != info->role) || (before.public_key.size != info->public_key.size) ||
|
||||||
|
(info->public_key.size > 0 && memcmp(before.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) ||
|
||||||
|
(before.bitfield != info->bitfield) || (info->channel != channelIndex);
|
||||||
|
|
||||||
info->user = lite;
|
if (info->public_key.size == 32) {
|
||||||
if (info->user.public_key.size == 32) {
|
printBytes("Saved Pubkey: ", info->public_key.bytes, 32);
|
||||||
printBytes("Saved Pubkey: ", info->user.public_key.bytes, 32);
|
|
||||||
}
|
}
|
||||||
if (nodeId != getNodeNum())
|
if (nodeId != getNodeNum())
|
||||||
info->channel = channelIndex; // Set channel we need to use to reach this node (but don't set our own channel)
|
info->channel = channelIndex; // Set channel we need to use to reach this node (but don't set our own channel)
|
||||||
LOG_DEBUG("Update changed=%d user %s/%s, id=0x%08x, channel=%d", changed, info->user.long_name, info->user.short_name, nodeId,
|
LOG_DEBUG("Update changed=%d user %s/%s, id=0x%08x, channel=%d", changed, info->long_name, info->short_name, nodeId,
|
||||||
info->channel);
|
info->channel);
|
||||||
info->has_user = true;
|
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
updateGUIforNode = info;
|
updateGUIforNode = info;
|
||||||
@@ -1956,7 +2376,8 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
|
|||||||
if (mp.rx_snr)
|
if (mp.rx_snr)
|
||||||
info->snr = mp.rx_snr; // keep the most recent SNR we received for this node.
|
info->snr = mp.rx_snr; // keep the most recent SNR we received for this node.
|
||||||
|
|
||||||
info->via_mqtt = mp.via_mqtt; // Store if we received this packet via MQTT
|
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK,
|
||||||
|
mp.via_mqtt); // Store if we received this packet via MQTT
|
||||||
|
|
||||||
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
|
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
|
||||||
const int8_t hopsAway = getHopsAway(mp);
|
const int8_t hopsAway = getHopsAway(mp);
|
||||||
@@ -1971,8 +2392,8 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
|
|||||||
void NodeDB::set_favorite(bool is_favorite, uint32_t nodeId)
|
void NodeDB::set_favorite(bool is_favorite, uint32_t nodeId)
|
||||||
{
|
{
|
||||||
meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);
|
meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);
|
||||||
if (lite && lite->is_favorite != is_favorite) {
|
if (lite && nodeInfoLiteIsFavorite(lite) != is_favorite) {
|
||||||
lite->is_favorite = is_favorite;
|
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_FAVORITE_MASK, is_favorite);
|
||||||
sortMeshDB();
|
sortMeshDB();
|
||||||
saveNodeDatabaseToDisk();
|
saveNodeDatabaseToDisk();
|
||||||
}
|
}
|
||||||
@@ -1986,10 +2407,10 @@ bool NodeDB::isFavorite(uint32_t nodeId)
|
|||||||
if (nodeId == NODENUM_BROADCAST)
|
if (nodeId == NODENUM_BROADCAST)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);
|
const meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);
|
||||||
|
|
||||||
if (lite) {
|
if (lite) {
|
||||||
return lite->is_favorite;
|
return nodeInfoLiteIsFavorite(lite);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2014,14 +2435,14 @@ bool NodeDB::isFromOrToFavoritedNode(const meshtastic_MeshPacket &p)
|
|||||||
lite = &meshNodes->at(i);
|
lite = &meshNodes->at(i);
|
||||||
|
|
||||||
if (lite->num == p.from) {
|
if (lite->num == p.from) {
|
||||||
if (lite->is_favorite)
|
if (nodeInfoLiteIsFavorite(lite))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
seenFrom = true;
|
seenFrom = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lite->num == p.to) {
|
if (lite->num == p.to) {
|
||||||
if (lite->is_favorite)
|
if (nodeInfoLiteIsFavorite(lite))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
seenTo = true;
|
seenTo = true;
|
||||||
@@ -2057,10 +2478,10 @@ void NodeDB::sortMeshDB()
|
|||||||
// TODO: Look for at(i-1) also matching own node num, and throw the DB in the trash
|
// TODO: Look for at(i-1) also matching own node num, and throw the DB in the trash
|
||||||
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
||||||
changed = true;
|
changed = true;
|
||||||
} else if (meshNodes->at(i).is_favorite && !meshNodes->at(i - 1).is_favorite) {
|
} else if (nodeInfoLiteIsFavorite(&meshNodes->at(i)) && !nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) {
|
||||||
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
||||||
changed = true;
|
changed = true;
|
||||||
} else if (!meshNodes->at(i).is_favorite && meshNodes->at(i - 1).is_favorite) {
|
} else if (!nodeInfoLiteIsFavorite(&meshNodes->at(i)) && nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) {
|
||||||
// noop
|
// noop
|
||||||
} else if (meshNodes->at(i).last_heard > meshNodes->at(i - 1).last_heard) {
|
} else if (meshNodes->at(i).last_heard > meshNodes->at(i - 1).last_heard) {
|
||||||
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
||||||
@@ -2120,17 +2541,18 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
|||||||
int oldestIndex = -1;
|
int oldestIndex = -1;
|
||||||
int oldestBoringIndex = -1;
|
int oldestBoringIndex = -1;
|
||||||
for (int i = 1; i < numMeshNodes; i++) {
|
for (int i = 1; i < numMeshNodes; i++) {
|
||||||
|
const meshtastic_NodeInfoLite *cand = &meshNodes->at(i);
|
||||||
|
const bool isFavoriteNode = nodeInfoLiteIsFavorite(cand);
|
||||||
|
const bool isIgnored = nodeInfoLiteIsIgnored(cand);
|
||||||
|
const bool isVerified = nodeInfoLiteIsKeyManuallyVerified(cand);
|
||||||
// Simply the oldest non-favorite, non-ignored, non-verified node
|
// Simply the oldest non-favorite, non-ignored, non-verified node
|
||||||
if (!meshNodes->at(i).is_favorite && !meshNodes->at(i).is_ignored &&
|
if (!isFavoriteNode && !isIgnored && !isVerified && cand->last_heard < oldest) {
|
||||||
!(meshNodes->at(i).bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK) &&
|
oldest = cand->last_heard;
|
||||||
meshNodes->at(i).last_heard < oldest) {
|
|
||||||
oldest = meshNodes->at(i).last_heard;
|
|
||||||
oldestIndex = i;
|
oldestIndex = i;
|
||||||
}
|
}
|
||||||
// The oldest "boring" node
|
// The oldest "boring" node
|
||||||
if (!meshNodes->at(i).is_favorite && !meshNodes->at(i).is_ignored && meshNodes->at(i).user.public_key.size == 0 &&
|
if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && cand->last_heard < oldestBoring) {
|
||||||
meshNodes->at(i).last_heard < oldestBoring) {
|
oldestBoring = cand->last_heard;
|
||||||
oldestBoring = meshNodes->at(i).last_heard;
|
|
||||||
oldestBoringIndex = i;
|
oldestBoringIndex = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2140,6 +2562,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (oldestIndex != -1) {
|
if (oldestIndex != -1) {
|
||||||
|
eraseNodeSatellites(meshNodes->at(oldestIndex).num);
|
||||||
// Shove the remaining nodes down the chain
|
// Shove the remaining nodes down the chain
|
||||||
for (int i = oldestIndex; i < numMeshNodes - 1; i++) {
|
for (int i = oldestIndex; i < numMeshNodes - 1; i++) {
|
||||||
meshNodes->at(i) = meshNodes->at(i + 1);
|
meshNodes->at(i) = meshNodes->at(i + 1);
|
||||||
@@ -2163,18 +2586,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
|||||||
/// valid lat/lon
|
/// valid lat/lon
|
||||||
bool NodeDB::hasValidPosition(const meshtastic_NodeInfoLite *n)
|
bool NodeDB::hasValidPosition(const meshtastic_NodeInfoLite *n)
|
||||||
{
|
{
|
||||||
return n->has_position && (n->position.latitude_i != 0 || n->position.longitude_i != 0);
|
if (!n)
|
||||||
|
return false;
|
||||||
|
if (n->num == getNodeNum()) {
|
||||||
|
return localPosition.latitude_i != 0 || localPosition.longitude_i != 0;
|
||||||
|
}
|
||||||
|
meshtastic_PositionLite pos;
|
||||||
|
return copyNodePosition(n->num, pos) && (pos.latitude_i != 0 || pos.longitude_i != 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If we have a node / user and they report is_licensed = true
|
/// If we have a node / user and they report is_licensed = true
|
||||||
/// we consider them licensed
|
/// we consider them licensed
|
||||||
UserLicenseStatus NodeDB::getLicenseStatus(uint32_t nodeNum)
|
UserLicenseStatus NodeDB::getLicenseStatus(uint32_t nodeNum)
|
||||||
{
|
{
|
||||||
meshtastic_NodeInfoLite *info = getMeshNode(nodeNum);
|
const meshtastic_NodeInfoLite *info = getMeshNode(nodeNum);
|
||||||
if (!info || !info->has_user) {
|
if (!nodeInfoLiteHasUser(info))
|
||||||
return UserLicenseStatus::NotKnown;
|
return UserLicenseStatus::NotKnown;
|
||||||
}
|
return nodeInfoLiteIsLicensed(info) ? UserLicenseStatus::Licensed : UserLicenseStatus::NotLicensed;
|
||||||
return info->user.is_licensed ? UserLicenseStatus::Licensed : UserLicenseStatus::NotLicensed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !defined(MESHTASTIC_EXCLUDE_PKI)
|
#if !defined(MESHTASTIC_EXCLUDE_PKI)
|
||||||
|
|||||||
+134
-4
@@ -6,10 +6,12 @@
|
|||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
#include <pb_encode.h>
|
#include <pb_encode.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
#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 for data we used to inline into NodeInfoLite,
|
||||||
|
// gated by MESHTASTIC_EXCLUDE_*DB so STM32WL can omit them.
|
||||||
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
||||||
|
std::unordered_map<NodeNum, meshtastic_PositionLite> nodePositions;
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||||
|
std::unordered_map<NodeNum, meshtastic_DeviceMetrics> nodeTelemetry;
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||||
|
std::unordered_map<NodeNum, meshtastic_EnvironmentMetrics> nodeEnvironment;
|
||||||
|
#endif
|
||||||
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
||||||
|
std::unordered_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,11 @@ 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();
|
||||||
};
|
};
|
||||||
|
|
||||||
extern NodeDB *nodeDB;
|
extern NodeDB *nodeDB;
|
||||||
@@ -397,10 +463,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;
|
||||||
|
}
|
||||||
+419
-7
@@ -62,7 +62,7 @@ void PhoneAPI::handleStartConfig()
|
|||||||
onConfigStart();
|
onConfigStart();
|
||||||
|
|
||||||
// even if we were already connected - restart our state machine
|
// even if we were already connected - restart our state machine
|
||||||
if (config_nonce == SPECIAL_NONCE_ONLY_NODES) {
|
if (config_nonce == SPECIAL_NONCE_ONLY_NODES || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) {
|
||||||
// If client only wants node info, jump directly to sending nodes
|
// If client only wants node info, jump directly to sending nodes
|
||||||
state = STATE_SEND_OWN_NODEINFO;
|
state = STATE_SEND_OWN_NODEINFO;
|
||||||
LOG_INFO("Client only wants node info, skipping other config");
|
LOG_INFO("Client only wants node info, skipping other config");
|
||||||
@@ -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,15 @@ 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;
|
||||||
}
|
}
|
||||||
packetForPhone = NULL;
|
packetForPhone = NULL;
|
||||||
filesManifest.clear();
|
filesManifest.clear();
|
||||||
@@ -302,7 +320,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
|||||||
nodeInfoForPhone.num = 0;
|
nodeInfoForPhone.num = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (config_nonce == SPECIAL_NONCE_ONLY_NODES) {
|
if (config_nonce == SPECIAL_NONCE_ONLY_NODES || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) {
|
||||||
// If client only wants node info, jump directly to sending nodes
|
// If client only wants node info, jump directly to sending nodes
|
||||||
state = STATE_SEND_OTHER_NODEINFOS;
|
state = STATE_SEND_OTHER_NODEINFOS;
|
||||||
onNowHasData(0);
|
onNowHasData(0);
|
||||||
@@ -529,8 +547,124 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
|||||||
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);
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
nodeInfoQueue.clear();
|
nodeInfoQueue.clear();
|
||||||
|
// Replay states no-op for legacy clients / excluded DBs.
|
||||||
|
state = STATE_REPLAY_POSITIONS;
|
||||||
|
return getFromRadio(buf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case STATE_REPLAY_POSITIONS: {
|
||||||
|
if (replayPositionOrder.empty() && replayPositionIndex == 0)
|
||||||
|
beginReplayPositions();
|
||||||
|
prefetchReplayPositions();
|
||||||
|
|
||||||
|
meshtastic_MeshPacket pkt = {};
|
||||||
|
bool havePkt = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
if (!replayQueue.empty()) {
|
||||||
|
pkt = replayQueue.front();
|
||||||
|
replayQueue.pop_front();
|
||||||
|
havePkt = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (havePkt) {
|
||||||
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||||
|
fromRadioScratch.packet = pkt;
|
||||||
|
} else {
|
||||||
|
LOG_DEBUG("Done replaying positions count=%u millis=%u", (unsigned)replayPositionIndex, millis());
|
||||||
|
state = STATE_REPLAY_TELEMETRY;
|
||||||
|
return getFromRadio(buf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case STATE_REPLAY_TELEMETRY: {
|
||||||
|
if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0)
|
||||||
|
beginReplayTelemetry();
|
||||||
|
prefetchReplayTelemetry();
|
||||||
|
|
||||||
|
meshtastic_MeshPacket pkt = {};
|
||||||
|
bool havePkt = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
if (!replayQueue.empty()) {
|
||||||
|
pkt = replayQueue.front();
|
||||||
|
replayQueue.pop_front();
|
||||||
|
havePkt = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (havePkt) {
|
||||||
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||||
|
fromRadioScratch.packet = pkt;
|
||||||
|
} else {
|
||||||
|
LOG_DEBUG("Done replaying telemetry count=%u millis=%u", (unsigned)replayTelemetryIndex, millis());
|
||||||
|
state = STATE_REPLAY_ENVIRONMENT;
|
||||||
|
return getFromRadio(buf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case STATE_REPLAY_ENVIRONMENT: {
|
||||||
|
if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0)
|
||||||
|
beginReplayEnvironment();
|
||||||
|
prefetchReplayEnvironment();
|
||||||
|
|
||||||
|
meshtastic_MeshPacket pkt = {};
|
||||||
|
bool havePkt = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
if (!replayQueue.empty()) {
|
||||||
|
pkt = replayQueue.front();
|
||||||
|
replayQueue.pop_front();
|
||||||
|
havePkt = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (havePkt) {
|
||||||
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||||
|
fromRadioScratch.packet = pkt;
|
||||||
|
} else {
|
||||||
|
LOG_DEBUG("Done replaying environment count=%u millis=%u", (unsigned)replayEnvironmentIndex, millis());
|
||||||
|
state = STATE_REPLAY_STATUS;
|
||||||
|
return getFromRadio(buf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case STATE_REPLAY_STATUS: {
|
||||||
|
if (replayStatusOrder.empty() && replayStatusIndex == 0)
|
||||||
|
beginReplayStatus();
|
||||||
|
prefetchReplayStatus();
|
||||||
|
|
||||||
|
meshtastic_MeshPacket pkt = {};
|
||||||
|
bool havePkt = false;
|
||||||
|
{
|
||||||
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
|
if (!replayQueue.empty()) {
|
||||||
|
pkt = replayQueue.front();
|
||||||
|
replayQueue.pop_front();
|
||||||
|
havePkt = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (havePkt) {
|
||||||
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||||
|
fromRadioScratch.packet = pkt;
|
||||||
|
} else {
|
||||||
|
LOG_DEBUG("Done replaying 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();
|
||||||
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 +672,9 @@ 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_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) {
|
||||||
config_state = 0;
|
config_state = 0;
|
||||||
filesManifest.clear();
|
filesManifest.clear();
|
||||||
// Skip to complete packet
|
// Skip to complete packet
|
||||||
@@ -653,6 +787,7 @@ void PhoneAPI::prefetchNodeInfos()
|
|||||||
{
|
{
|
||||||
bool added = false;
|
bool added = false;
|
||||||
bool wasEmpty = false;
|
bool wasEmpty = false;
|
||||||
|
const bool gradient = clientWantsGradientSync();
|
||||||
// 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 +797,8 @@ void PhoneAPI::prefetchNodeInfos()
|
|||||||
if (!nextNode)
|
if (!nextNode)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
auto info = TypeConversions::ConvertToNodeInfo(nextNode);
|
auto info =
|
||||||
|
gradient ? TypeConversions::ConvertToNodeInfoThin(nextNode) : TypeConversions::ConvertToNodeInfo(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 +818,257 @@ void PhoneAPI::prefetchNodeInfos()
|
|||||||
onNowHasData(0);
|
onNowHasData(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos)
|
||||||
|
{
|
||||||
|
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default;
|
||||||
|
pkt.from = num;
|
||||||
|
pkt.to = nodeDB->getNodeNum();
|
||||||
|
pkt.id = generatePacketId();
|
||||||
|
pkt.rx_time = pos.time;
|
||||||
|
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 = nodeDB->getNodeNum();
|
||||||
|
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.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 state arm
|
||||||
|
// immediately drains and advances.
|
||||||
|
replayPositionOrder.clear();
|
||||||
|
replayPositionIndex = 0;
|
||||||
|
#else
|
||||||
|
if (!clientWantsGradientSync()) {
|
||||||
|
replayPositionOrder.clear();
|
||||||
|
replayPositionIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
if (!clientWantsGradientSync())
|
||||||
|
return;
|
||||||
|
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
|
||||||
|
if (!clientWantsGradientSync()) {
|
||||||
|
replayTelemetryOrder.clear();
|
||||||
|
replayTelemetryIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if (!clientWantsGradientSync())
|
||||||
|
return;
|
||||||
|
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 = nodeDB->getNodeNum();
|
||||||
|
pkt.id = generatePacketId();
|
||||||
|
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
|
||||||
|
pkt.rx_time = header ? header->last_heard : 0;
|
||||||
|
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
|
||||||
|
if (!clientWantsGradientSync()) {
|
||||||
|
replayEnvironmentOrder.clear();
|
||||||
|
replayEnvironmentIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if (!clientWantsGradientSync())
|
||||||
|
return;
|
||||||
|
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 = nodeDB->getNodeNum();
|
||||||
|
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.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
|
||||||
|
if (!clientWantsGradientSync()) {
|
||||||
|
replayStatusOrder.clear();
|
||||||
|
replayStatusIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if (!clientWantsGradientSync())
|
||||||
|
return;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
void PhoneAPI::releaseMqttClientProxyPhonePacket()
|
void PhoneAPI::releaseMqttClientProxyPhonePacket()
|
||||||
{
|
{
|
||||||
if (mqttClientProxyMessageForPhone) {
|
if (mqttClientProxyMessageForPhone) {
|
||||||
@@ -728,6 +1115,31 @@ bool PhoneAPI::available()
|
|||||||
PREFETCH_NODEINFO:
|
PREFETCH_NODEINFO:
|
||||||
prefetchNodeInfos();
|
prefetchNodeInfos();
|
||||||
return true;
|
return true;
|
||||||
|
case STATE_REPLAY_POSITIONS: {
|
||||||
|
// Prime the iterator if we haven't yet, then top up the queue.
|
||||||
|
if (replayPositionOrder.empty() && replayPositionIndex == 0)
|
||||||
|
beginReplayPositions();
|
||||||
|
prefetchReplayPositions();
|
||||||
|
return true; // Always advance state machine; arm itself transitions when drained
|
||||||
|
}
|
||||||
|
case STATE_REPLAY_TELEMETRY: {
|
||||||
|
if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0)
|
||||||
|
beginReplayTelemetry();
|
||||||
|
prefetchReplayTelemetry();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case STATE_REPLAY_ENVIRONMENT: {
|
||||||
|
if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0)
|
||||||
|
beginReplayEnvironment();
|
||||||
|
prefetchReplayEnvironment();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case STATE_REPLAY_STATUS: {
|
||||||
|
if (replayStatusOrder.empty() && replayStatusIndex == 0)
|
||||||
|
beginReplayStatus();
|
||||||
|
prefetchReplayStatus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
case STATE_SEND_PACKETS: {
|
case STATE_SEND_PACKETS: {
|
||||||
if (!queueStatusPacketForPhone)
|
if (!queueStatusPacketForPhone)
|
||||||
queueStatusPacketForPhone = service->getQueueStatusForPhone();
|
queueStatusPacketForPhone = service->getQueueStatusForPhone();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -22,6 +26,9 @@
|
|||||||
|
|
||||||
#define SPECIAL_NONCE_ONLY_CONFIG 69420
|
#define SPECIAL_NONCE_ONLY_CONFIG 69420
|
||||||
#define SPECIAL_NONCE_ONLY_NODES 69421 // ( ͡° ͜ʖ ͡°)
|
#define SPECIAL_NONCE_ONLY_NODES 69421 // ( ͡° ͜ʖ ͡°)
|
||||||
|
// Gradient sync: phone sends one of these to opt into thin-header + replay.
|
||||||
|
#define SPECIAL_NONCE_GRADIENT_SYNC 69422
|
||||||
|
#define SPECIAL_NONCE_GRADIENT_ONLY_NODES 69423
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides our protobuf based API which phone/PC clients can use to talk to our device
|
* Provides our protobuf based API which phone/PC clients can use to talk to our device
|
||||||
@@ -45,6 +52,12 @@ class PhoneAPI
|
|||||||
STATE_SEND_CONFIG, // Replacement for the old Radioconfig
|
STATE_SEND_CONFIG, // Replacement for the old Radioconfig
|
||||||
STATE_SEND_MODULECONFIG, // Send Module specific config
|
STATE_SEND_MODULECONFIG, // Send Module specific config
|
||||||
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
|
||||||
|
// Drain satellite DBs as synthetic POSITION_APP / TELEMETRY_APP /
|
||||||
|
// NODE_STATUS_APP packets when the phone opted into gradient sync.
|
||||||
|
STATE_REPLAY_POSITIONS,
|
||||||
|
STATE_REPLAY_TELEMETRY,
|
||||||
|
STATE_REPLAY_ENVIRONMENT,
|
||||||
|
STATE_REPLAY_STATUS,
|
||||||
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 // send packets or debug strings
|
||||||
@@ -88,6 +101,20 @@ 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;
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -137,6 +164,10 @@ class PhoneAPI
|
|||||||
|
|
||||||
bool isConnected() { return state != STATE_SEND_NOTHING; }
|
bool isConnected() { return state != STATE_SEND_NOTHING; }
|
||||||
bool isSendingPackets() { return state == STATE_SEND_PACKETS; }
|
bool isSendingPackets() { return state == STATE_SEND_PACKETS; }
|
||||||
|
bool clientWantsGradientSync() const
|
||||||
|
{
|
||||||
|
return config_nonce == SPECIAL_NONCE_GRADIENT_SYNC || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES;
|
||||||
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
/// Our fromradio packet while it is being assembled
|
/// Our fromradio packet while it is being assembled
|
||||||
@@ -185,6 +216,18 @@ 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);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,56 @@ 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;
|
||||||
}
|
}
|
||||||
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;
|
||||||
@@ -77,46 +93,50 @@ meshtastic_Position TypeConversions::ConvertToPosition(meshtastic_PositionLite l
|
|||||||
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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,43 +63,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 +135,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 +166,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 */
|
||||||
@@ -191,16 +213,24 @@ extern "C" {
|
|||||||
/* 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}
|
||||||
#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}
|
||||||
#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}
|
||||||
|
|
||||||
@@ -219,18 +249,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 +269,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
|
||||||
@@ -275,23 +316,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 +349,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 +418,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 +431,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,7 +445,11 @@ 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_NodeInfoLite_size 105
|
||||||
|
#define meshtastic_NodePositionEntry_size 36
|
||||||
|
#define meshtastic_NodeStatusEntry_size 89
|
||||||
|
#define meshtastic_NodeTelemetryEntry_size 35
|
||||||
#define meshtastic_PositionLite_size 28
|
#define meshtastic_PositionLite_size 28
|
||||||
#define meshtastic_UserLite_size 98
|
#define meshtastic_UserLite_size 98
|
||||||
|
|
||||||
|
|||||||
@@ -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 196
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -317,6 +317,8 @@ 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,
|
||||||
/* ------------------------------------------------------------------------------------------------------------------------------------------
|
/* ------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
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.
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------ */
|
------------------------------------------------------------------------------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
meshtastic_PositionLite posLite;
|
||||||
|
if (nodeDB->copyNodePosition(tempNodeInfo->num, posLite)) {
|
||||||
JSONObject position;
|
JSONObject position;
|
||||||
position["latitude"] = new JSONValue((float)tempNodeInfo->position.latitude_i * 1e-7);
|
position["latitude"] = new JSONValue((float)posLite.latitude_i * 1e-7);
|
||||||
position["longitude"] = new JSONValue((float)tempNodeInfo->position.longitude_i * 1e-7);
|
position["longitude"] = new JSONValue((float)posLite.longitude_i * 1e-7);
|
||||||
position["altitude"] = new JSONValue((int)tempNodeInfo->position.altitude);
|
position["altitude"] = new JSONValue((int)posLite.altitude);
|
||||||
node["position"] = new JSONValue(position);
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,43 @@
|
|||||||
#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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +303,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
|
||||||
@@ -384,13 +453,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 +521,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 +563,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,6 +49,7 @@ 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);
|
||||||
|
if (remoteNodePtr)
|
||||||
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
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) {
|
||||||
@@ -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,6 +108,7 @@ 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);
|
||||||
|
if (remoteNodePtr)
|
||||||
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,25 +269,35 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp)
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,10 +250,13 @@ 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;
|
||||||
|
if (nodeDB->copyNodePosition(tempNodeInfo->num, pos)) {
|
||||||
|
printWPL(outbuf, sizeof(outbuf), pos, tempNodeInfo->long_name, true);
|
||||||
serialPrint->printf("%s", outbuf);
|
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,13 +420,17 @@ 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,
|
{
|
||||||
|
meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp));
|
||||||
|
const char *senderName = senderNode ? senderNode->long_name : "";
|
||||||
|
printWPL(outbuf, sizeof(outbuf), *decoded, senderName,
|
||||||
moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO);
|
moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO);
|
||||||
serialPrint->printf("%s", outbuf);
|
serialPrint->printf("%s", outbuf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return ProcessMessage::CONTINUE; // Let others look at this message also if they want
|
return ProcessMessage::CONTINUE; // Let others look at this message also if they want
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,23 @@ int32_t StatusLEDModule::runOnce()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If we want a LED to be dedicated to the simple hearbeat, we can use that instead of the charge LED
|
||||||
if (power_state != charging && power_state != charged && !doing_fast_blink) {
|
#if defined(LED_HEARTBEAT)
|
||||||
|
if (power_state != charging && power_state != charged && !doing_fast_blink && !config.device.led_heartbeat_disabled) {
|
||||||
|
if (HEARTBEAT_LED_state == LED_STATE_ON) {
|
||||||
|
HEARTBEAT_LED_state = LED_STATE_OFF;
|
||||||
|
my_interval = 999;
|
||||||
|
} else {
|
||||||
|
HEARTBEAT_LED_state = LED_STATE_ON;
|
||||||
|
my_interval = 1;
|
||||||
|
}
|
||||||
|
digitalWrite(LED_HEARTBEAT, HEARTBEAT_LED_state);
|
||||||
|
} else {
|
||||||
|
HEARTBEAT_LED_state = LED_STATE_OFF;
|
||||||
|
digitalWrite(LED_HEARTBEAT, HEARTBEAT_LED_state);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if (power_state != charging && power_state != charged && !doing_fast_blink && !config.device.led_heartbeat_disabled) {
|
||||||
if (CHARGE_LED_state == LED_STATE_ON) {
|
if (CHARGE_LED_state == LED_STATE_ON) {
|
||||||
CHARGE_LED_state = LED_STATE_OFF;
|
CHARGE_LED_state = LED_STATE_OFF;
|
||||||
my_interval = 999;
|
my_interval = 999;
|
||||||
@@ -105,7 +120,7 @@ int32_t StatusLEDModule::runOnce()
|
|||||||
my_interval = 1;
|
my_interval = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) {
|
if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) {
|
||||||
PAIRING_LED_state = LED_STATE_OFF;
|
PAIRING_LED_state = LED_STATE_OFF;
|
||||||
} else if (ble_state == unpaired) {
|
} else if (ble_state == unpaired) {
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ class StatusLEDModule : private concurrency::OSThread
|
|||||||
private:
|
private:
|
||||||
bool CHARGE_LED_state = LED_STATE_OFF;
|
bool CHARGE_LED_state = LED_STATE_OFF;
|
||||||
bool PAIRING_LED_state = LED_STATE_OFF;
|
bool PAIRING_LED_state = LED_STATE_OFF;
|
||||||
|
#if defined(LED_HEARTBEAT)
|
||||||
|
bool HEARTBEAT_LED_state = LED_STATE_OFF;
|
||||||
|
#endif
|
||||||
|
|
||||||
uint32_t PAIRING_LED_starttime = 0;
|
uint32_t PAIRING_LED_starttime = 0;
|
||||||
uint32_t lastUserbuttonTime = 0;
|
uint32_t lastUserbuttonTime = 0;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "StatusMessageModule.h"
|
#include "StatusMessageModule.h"
|
||||||
#include "MeshService.h"
|
#include "MeshService.h"
|
||||||
|
#include "NodeDB.h"
|
||||||
#include "ProtobufModule.h"
|
#include "ProtobufModule.h"
|
||||||
|
|
||||||
StatusMessageModule *statusMessageModule;
|
StatusMessageModule *statusMessageModule;
|
||||||
@@ -36,16 +37,8 @@ ProcessMessage StatusMessageModule::handleReceived(const meshtastic_MeshPacket &
|
|||||||
|
|
||||||
LOG_INFO("Received a NodeStatus message %s", incomingMessage.status);
|
LOG_INFO("Received a NodeStatus message %s", incomingMessage.status);
|
||||||
|
|
||||||
RecentStatus entry;
|
if (nodeDB)
|
||||||
entry.fromNodeId = mp.from;
|
nodeDB->setNodeStatus(mp.from, incomingMessage);
|
||||||
entry.statusText = incomingMessage.status;
|
|
||||||
|
|
||||||
recentReceived.push_back(std::move(entry));
|
|
||||||
|
|
||||||
// Keep only last MAX_RECENT_STATUSMESSAGES
|
|
||||||
if (recentReceived.size() > MAX_RECENT_STATUSMESSAGES) {
|
|
||||||
recentReceived.erase(recentReceived.begin()); // drop oldest
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ProcessMessage::CONTINUE;
|
return ProcessMessage::CONTINUE;
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||||
#include "SinglePortModule.h"
|
#include "SinglePortModule.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
class StatusMessageModule : public SinglePortModule, private concurrency::OSThread
|
class StatusMessageModule : public SinglePortModule, private concurrency::OSThread
|
||||||
{
|
{
|
||||||
@@ -20,28 +18,15 @@ class StatusMessageModule : public SinglePortModule, private concurrency::OSThre
|
|||||||
this->setInterval(1000 * 12 * 60 * 60);
|
this->setInterval(1000 * 12 * 60 * 60);
|
||||||
}
|
}
|
||||||
// TODO: If we have a string, set the initial delay (15 minutes maybe)
|
// TODO: If we have a string, set the initial delay (15 minutes maybe)
|
||||||
|
|
||||||
// Keep vector from reallocating as we fill up to MAX_RECENT_STATUSMESSAGES
|
|
||||||
recentReceived.reserve(MAX_RECENT_STATUSMESSAGES);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
virtual int32_t runOnce() override;
|
virtual int32_t runOnce() override;
|
||||||
|
|
||||||
struct RecentStatus {
|
|
||||||
uint32_t fromNodeId; // mp.from
|
|
||||||
std::string statusText; // incomingMessage.status
|
|
||||||
};
|
|
||||||
|
|
||||||
const std::vector<RecentStatus> &getRecentReceived() const { return recentReceived; }
|
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
/** Called to handle a particular incoming message
|
/** Called to handle a particular incoming message. The cached most-recent
|
||||||
|
* status for each node lives on NodeDB; use nodeDB->copyNodeStatus(num, ...).
|
||||||
*/
|
*/
|
||||||
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||||
|
|
||||||
private:
|
|
||||||
static constexpr size_t MAX_RECENT_STATUSMESSAGES = 5;
|
|
||||||
std::vector<RecentStatus> recentReceived;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
extern StatusMessageModule *statusMessageModule;
|
extern StatusMessageModule *statusMessageModule;
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event)
|
|||||||
case INPUT_BROKER_SHUTDOWN:
|
case INPUT_BROKER_SHUTDOWN:
|
||||||
shutdownAtMsec = millis();
|
shutdownAtMsec = millis();
|
||||||
return true;
|
return true;
|
||||||
|
// factory reset
|
||||||
|
case INPUT_BROKER_FACTORY_RST:
|
||||||
|
disableBluetooth();
|
||||||
|
LOG_INFO("Initiate full factory reset");
|
||||||
|
nodeDB->factoryReset(true);
|
||||||
|
// reboot(DEFAULT_REBOOT_SECONDS);
|
||||||
|
LOG_INFO("Reboot in %d seconds", DEFAULT_REBOOT_SECONDS);
|
||||||
|
if (screen)
|
||||||
|
screen->showSimpleBanner("Rebooting...", 0); // stays on screen
|
||||||
|
rebootAtMsec = (DEFAULT_REBOOT_SECONDS < 0) ? 0 : (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||||
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// No other input events handled here
|
// No other input events handled here
|
||||||
|
|||||||
@@ -515,6 +515,10 @@ bool EnvironmentTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPac
|
|||||||
packetPool.release(lastMeasurementPacket);
|
packetPool.release(lastMeasurementPacket);
|
||||||
|
|
||||||
lastMeasurementPacket = packetPool.allocCopy(mp);
|
lastMeasurementPacket = packetPool.allocCopy(mp);
|
||||||
|
|
||||||
|
// Cache the latest env metrics per node on NodeDB so the phone can
|
||||||
|
// pull last-known values across reboots and replays.
|
||||||
|
nodeDB->updateTelemetry(getFrom(&mp), *t, RX_SRC_RADIO);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false; // Let others look at this message also if they want
|
return false; // Let others look at this message also if they want
|
||||||
|
|||||||
@@ -492,12 +492,12 @@ TraceRouteModule::TraceRouteModule()
|
|||||||
const char *TraceRouteModule::getNodeName(NodeNum node)
|
const char *TraceRouteModule::getNodeName(NodeNum node)
|
||||||
{
|
{
|
||||||
meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node);
|
meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node);
|
||||||
if (info && info->has_user) {
|
if (nodeInfoLiteHasUser(info)) {
|
||||||
if (strlen(info->user.short_name) > 0) {
|
if (strlen(info->short_name) > 0) {
|
||||||
return info->user.short_name;
|
return info->short_name;
|
||||||
}
|
}
|
||||||
if (strlen(info->user.long_name) > 0) {
|
if (strlen(info->long_name) > 0) {
|
||||||
return info->user.long_name;
|
return info->long_name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1226,9 +1226,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke
|
|||||||
// Fallback only when PSRAM cache is unavailable on this target.
|
// Fallback only when PSRAM cache is unavailable on this target.
|
||||||
// In this mode we use the node-wide table maintained by NodeInfoModule.
|
// In this mode we use the node-wide table maintained by NodeInfoModule.
|
||||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to);
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to);
|
||||||
if (!node || !node->has_user)
|
if (!nodeInfoLiteHasUser(node))
|
||||||
return false;
|
return false;
|
||||||
cachedUser = TypeConversions::ConvertToUser(node->num, node->user);
|
cachedUser = TypeConversions::ConvertToUser(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sendResponse)
|
if (!sendResponse)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state,
|
|||||||
char distStr[20] = "";
|
char distStr[20] = "";
|
||||||
|
|
||||||
// Get our node, to use our own position
|
// Get our node, to use our own position
|
||||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
|
|
||||||
// Match compass sizing/placement to favorite node screen logic.
|
// Match compass sizing/placement to favorite node screen logic.
|
||||||
const int w = display->getWidth();
|
const int w = display->getWidth();
|
||||||
@@ -147,8 +147,10 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state,
|
|||||||
const char *statusLine2 = nullptr;
|
const char *statusLine2 = nullptr;
|
||||||
|
|
||||||
// Distance only needs our own position fix; compass/bearing additionally needs heading.
|
// Distance only needs our own position fix; compass/bearing additionally needs heading.
|
||||||
if (hasOwnPositionFix) {
|
meshtastic_PositionLite ownPos;
|
||||||
const meshtastic_PositionLite &op = ourNode->position;
|
const bool haveOwnPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ownPos);
|
||||||
|
if (hasOwnPositionFix && haveOwnPos) {
|
||||||
|
const meshtastic_PositionLite &op = ownPos;
|
||||||
const float d =
|
const float d =
|
||||||
GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -22,6 +22,9 @@
|
|||||||
#if HAS_ETHERNET && defined(USE_WS5500)
|
#if HAS_ETHERNET && defined(USE_WS5500)
|
||||||
#include <ETHClass2.h>
|
#include <ETHClass2.h>
|
||||||
#define ETH ETH2
|
#define ETH ETH2
|
||||||
|
#elif HAS_ETHERNET && defined(USE_CH390D)
|
||||||
|
#include "ESP32_CH390.h"
|
||||||
|
#define ETH CH390
|
||||||
#endif // HAS_ETHERNET
|
#endif // HAS_ETHERNET
|
||||||
#include "Default.h"
|
#include "Default.h"
|
||||||
#if !defined(ARCH_NRF52) || NRF52_USE_JSON
|
#if !defined(ARCH_NRF52) || NRF52_USE_JSON
|
||||||
@@ -141,7 +144,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
|||||||
const meshtastic_NodeInfoLite *rx = nodeDB->getMeshNode(p->to);
|
const meshtastic_NodeInfoLite *rx = nodeDB->getMeshNode(p->to);
|
||||||
// Only accept PKI messages to us, or if we have both the sender and receiver in our nodeDB, as then it's
|
// Only accept PKI messages to us, or if we have both the sender and receiver in our nodeDB, as then it's
|
||||||
// likely they discovered each other via a channel we have downlink enabled for
|
// likely they discovered each other via a channel we have downlink enabled for
|
||||||
if (isToUs(p.get()) || (tx && tx->has_user && rx && rx->has_user))
|
if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx)))
|
||||||
router->enqueueReceivedMessage(p.release());
|
router->enqueueReceivedMessage(p.release());
|
||||||
} else if (router &&
|
} else if (router &&
|
||||||
perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key
|
perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key
|
||||||
@@ -344,6 +347,9 @@ inline bool isConnectedToNetwork()
|
|||||||
#ifdef USE_WS5500
|
#ifdef USE_WS5500
|
||||||
if (ETH.connected())
|
if (ETH.connected())
|
||||||
return true;
|
return true;
|
||||||
|
#elif defined(USE_CH390D)
|
||||||
|
if (ETH.isConnected())
|
||||||
|
return true;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if HAS_WIFI
|
#if HAS_WIFI
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
#include <WiFiClientSecure.h>
|
#include <WiFiClientSecure.h>
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||||
#include <EthernetClient.h>
|
#include <EthernetClient.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,8 @@
|
|||||||
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M2
|
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M2
|
||||||
#elif defined(ELECROW_ThinkNode_M5)
|
#elif defined(ELECROW_ThinkNode_M5)
|
||||||
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M5
|
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M5
|
||||||
|
#elif defined(ELECROW_ThinkNode_M7)
|
||||||
|
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M7
|
||||||
#elif defined(ESP32_S3_PICO)
|
#elif defined(ESP32_S3_PICO)
|
||||||
#define HW_VENDOR meshtastic_HardwareModel_ESP32_S3_PICO
|
#define HW_VENDOR meshtastic_HardwareModel_ESP32_S3_PICO
|
||||||
#elif defined(SENSELORA_S3)
|
#elif defined(SENSELORA_S3)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ void variant_shutdown() {}
|
|||||||
#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
|
#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
|
||||||
void setBluetoothEnable(bool enable)
|
void setBluetoothEnable(bool enable)
|
||||||
{
|
{
|
||||||
#ifdef USE_WS5500
|
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||||
if ((config.bluetooth.enabled == true) && (config.network.wifi_enabled == false))
|
if ((config.bluetooth.enabled == true) && (config.network.wifi_enabled == false))
|
||||||
#elif HAS_WIFI
|
#elif HAS_WIFI
|
||||||
if (!isWifiAvailable() && config.bluetooth.enabled == true)
|
if (!isWifiAvailable() && config.bluetooth.enabled == true)
|
||||||
|
|||||||
@@ -319,10 +319,14 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
|||||||
// Lambda function for adding a long name to the route
|
// Lambda function for adding a long name to the route
|
||||||
auto addToRoute = [](JSONArray *route, NodeNum num) {
|
auto addToRoute = [](JSONArray *route, NodeNum num) {
|
||||||
char long_name[40] = "Unknown";
|
char long_name[40] = "Unknown";
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num);
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num);
|
||||||
bool name_known = node ? node->has_user : false;
|
bool name_known = nodeInfoLiteHasUser(node);
|
||||||
if (name_known)
|
if (name_known) {
|
||||||
memcpy(long_name, node->user.long_name, sizeof(long_name));
|
const size_t copy_len =
|
||||||
|
(sizeof(node->long_name) < sizeof(long_name)) ? sizeof(node->long_name) : sizeof(long_name) - 1;
|
||||||
|
memcpy(long_name, node->long_name, copy_len);
|
||||||
|
long_name[copy_len] = '\0';
|
||||||
|
}
|
||||||
route->push_back(new JSONValue(long_name));
|
route->push_back(new JSONValue(long_name));
|
||||||
};
|
};
|
||||||
addToRoute(&route, mp->to); // Started at the original transmitter (destination of response)
|
addToRoute(&route, mp->to); // Started at the original transmitter (destination of response)
|
||||||
|
|||||||
@@ -292,9 +292,13 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
|||||||
auto addToRoute = [](JsonArray *route, NodeNum num) {
|
auto addToRoute = [](JsonArray *route, NodeNum num) {
|
||||||
char long_name[40] = "Unknown";
|
char long_name[40] = "Unknown";
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num);
|
||||||
bool name_known = node ? node->has_user : false;
|
bool name_known = nodeInfoLiteHasUser(node);
|
||||||
if (name_known)
|
if (name_known) {
|
||||||
memcpy(long_name, node->user.long_name, sizeof(long_name));
|
const size_t copy_len =
|
||||||
|
(sizeof(node->long_name) < sizeof(long_name)) ? sizeof(node->long_name) : sizeof(long_name) - 1;
|
||||||
|
memcpy(long_name, node->long_name, copy_len);
|
||||||
|
long_name[copy_len] = '\0';
|
||||||
|
}
|
||||||
route->add(long_name);
|
route->add(long_name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ void test_DH25519(void)
|
|||||||
void test_PKC(void)
|
void test_PKC(void)
|
||||||
{
|
{
|
||||||
uint8_t private_key[32];
|
uint8_t private_key[32];
|
||||||
meshtastic_UserLite_public_key_t public_key;
|
meshtastic_NodeInfoLite_public_key_t public_key;
|
||||||
uint8_t expected_shared[32];
|
uint8_t expected_shared[32];
|
||||||
uint8_t expected_decrypted[32];
|
uint8_t expected_decrypted[32];
|
||||||
uint8_t radioBytes[128] __attribute__((__aligned__));
|
uint8_t radioBytes[128] __attribute__((__aligned__));
|
||||||
|
|||||||
@@ -641,30 +641,12 @@ void test_sender_zero_substituted(void)
|
|||||||
TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false));
|
TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_uninitialized_wasSeenRecently(void)
|
// NOTE: Tests for the !initOk() short-circuit in wasSeenRecently / wasRelayer
|
||||||
{
|
// were removed: the only way to drive that branch from a test is to destruct
|
||||||
// Simulate uninitialized state — create a PacketHistory that looks uninitialized
|
// the object and then call methods on it, which is UB and trips ASAN under
|
||||||
// We can't easily make allocation fail, but we can test the initOk guard with a destructed one
|
// `pio test -e coverage`. In practice the guard can only fire if `new[]` throws
|
||||||
PacketHistory h(4);
|
// (in which case the constructor doesn't return), so the lost coverage is
|
||||||
TEST_ASSERT_TRUE(h.initOk()); // sanity check
|
// purely defensive.
|
||||||
h.~PacketHistory();
|
|
||||||
|
|
||||||
auto p = makePacket(0x1111, 100);
|
|
||||||
TEST_ASSERT_FALSE(h.wasSeenRecently(&p));
|
|
||||||
|
|
||||||
// Reconstruct in place to allow proper destruction
|
|
||||||
new (&h) PacketHistory(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_uninitialized_wasRelayer(void)
|
|
||||||
{
|
|
||||||
PacketHistory h(4);
|
|
||||||
h.~PacketHistory();
|
|
||||||
|
|
||||||
TEST_ASSERT_FALSE(h.wasRelayer(0xAA, 100, 0x1111));
|
|
||||||
|
|
||||||
new (&h) PacketHistory(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_multiple_instances_independent(void)
|
void test_multiple_instances_independent(void)
|
||||||
{
|
{
|
||||||
@@ -819,8 +801,6 @@ void setup()
|
|||||||
// Group 10 — Edge Cases
|
// Group 10 — Edge Cases
|
||||||
RUN_TEST(test_packet_id_zero_not_stored);
|
RUN_TEST(test_packet_id_zero_not_stored);
|
||||||
RUN_TEST(test_sender_zero_substituted);
|
RUN_TEST(test_sender_zero_substituted);
|
||||||
RUN_TEST(test_uninitialized_wasSeenRecently);
|
|
||||||
RUN_TEST(test_uninitialized_wasRelayer);
|
|
||||||
RUN_TEST(test_multiple_instances_independent);
|
RUN_TEST(test_multiple_instances_independent);
|
||||||
|
|
||||||
// Group 11 — Hash Table Stress
|
// Group 11 — Hash Table Stress
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class MockNodeDB : public NodeDB
|
|||||||
hasCachedNode = true;
|
hasCachedNode = true;
|
||||||
cachedNodeNum = n;
|
cachedNodeNum = n;
|
||||||
cachedNode.num = n;
|
cachedNode.num = n;
|
||||||
cachedNode.has_user = true;
|
cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -494,9 +494,9 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void)
|
|||||||
|
|
||||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||||||
TEST_ASSERT_NOT_NULL(requestor);
|
TEST_ASSERT_NOT_NULL(requestor);
|
||||||
TEST_ASSERT_TRUE(requestor->has_user);
|
TEST_ASSERT_TRUE((requestor->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0);
|
||||||
TEST_ASSERT_EQUAL_STRING("requester-long", requestor->user.long_name);
|
TEST_ASSERT_EQUAL_STRING("requester-long", requestor->long_name);
|
||||||
TEST_ASSERT_EQUAL_STRING("rq", requestor->user.short_name);
|
TEST_ASSERT_EQUAL_STRING("rq", requestor->short_name);
|
||||||
TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel);
|
TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
// Tests for src/mesh/TypeConversions.cpp covering:
|
||||||
|
// - bitfield bit collapse on store + extraction round-trip
|
||||||
|
// - long_name / short_name truncation at the new max_size:25 / 5 boundaries
|
||||||
|
// - public_key / hw_model / role pass-through
|
||||||
|
// - thin vs bundled NodeInfo emission
|
||||||
|
//
|
||||||
|
// All exercised via the explicit-args overload of ConvertToNodeInfo so we don't
|
||||||
|
// touch the global nodeDB pointer (which isn't initialized in this test env).
|
||||||
|
|
||||||
|
#include "NodeDB.h"
|
||||||
|
#include "TestUtil.h"
|
||||||
|
#include "TypeConversions.h"
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
void setUp(void) {}
|
||||||
|
void tearDown(void) {}
|
||||||
|
|
||||||
|
// ---------- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
static meshtastic_User makeUser(const char *longName, const char *shortName)
|
||||||
|
{
|
||||||
|
meshtastic_User u = meshtastic_User_init_zero;
|
||||||
|
if (longName)
|
||||||
|
strncpy(u.long_name, longName, sizeof(u.long_name) - 1);
|
||||||
|
if (shortName)
|
||||||
|
strncpy(u.short_name, shortName, sizeof(u.short_name) - 1);
|
||||||
|
u.hw_model = meshtastic_HardwareModel_TBEAM;
|
||||||
|
u.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- has_user / id / macaddr ------------------------------------------
|
||||||
|
|
||||||
|
void test_copy_user_sets_has_user_bit(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User u = makeUser("Kevin Hester", "kh");
|
||||||
|
TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0);
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_user_id_derived_from_nodenum(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0x12345678;
|
||||||
|
meshtastic_User u = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("!12345678", u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_user_zero_fills_macaddr(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0xCAFEBABE;
|
||||||
|
meshtastic_User u = TypeConversions::ConvertToUser(&lite);
|
||||||
|
uint8_t zeros[sizeof(u.macaddr)] = {0};
|
||||||
|
TEST_ASSERT_EQUAL_MEMORY(zeros, u.macaddr, sizeof(u.macaddr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- long_name truncation ---------------------------------------------
|
||||||
|
|
||||||
|
void test_long_name_short_passes_through(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User u = makeUser("Kevin Hester", "kh"); // 12 chars, fits
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Kevin Hester", lite.long_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_long_name_exact_24_fits(void)
|
||||||
|
{
|
||||||
|
// 24 chars -> stored as 24 chars + NUL inside char[25].
|
||||||
|
const char *exact24 = "abcdefghijklmnopqrstuvwx";
|
||||||
|
TEST_ASSERT_EQUAL_INT(24, (int)strlen(exact24));
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User u = makeUser(exact24, "ex");
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||||
|
TEST_ASSERT_EQUAL_STRING(exact24, lite.long_name);
|
||||||
|
TEST_ASSERT_EQUAL_INT(24, (int)strlen(lite.long_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_long_name_truncates_when_too_long(void)
|
||||||
|
{
|
||||||
|
// 33 chars in, must fit in 24 + NUL.
|
||||||
|
const char *tooLong = "North-County Search & Rescue Base";
|
||||||
|
TEST_ASSERT_EQUAL_INT(33, (int)strlen(tooLong));
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User u = makeUser(tooLong, "nc");
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||||
|
TEST_ASSERT_EQUAL_INT(24, (int)strlen(lite.long_name));
|
||||||
|
TEST_ASSERT_EQUAL_STRING_LEN(tooLong, lite.long_name, 24);
|
||||||
|
TEST_ASSERT_EQUAL_INT('\0', lite.long_name[24]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_long_name_round_trip_to_wire(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("Mountain Repeater Site E", "mr"); // exactly 24
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
meshtastic_User out = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_EQUAL_STRING(in.long_name, out.long_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_long_name_truncated_utf8_boundary_sanitized(void)
|
||||||
|
{
|
||||||
|
// Suffix the 24th byte with the start of a 4-byte emoji; truncation should
|
||||||
|
// leave the dangling bytes for sanitizeUtf8 to replace with '?'.
|
||||||
|
char input[40] = {0};
|
||||||
|
memset(input, 'a', 22); // 22 ASCII
|
||||||
|
input[22] = static_cast<char>(0xF0); // emoji lead byte at position 22
|
||||||
|
input[23] = static_cast<char>(0x9F); // continuation
|
||||||
|
input[24] = static_cast<char>(0xA4); // continuation - past the cap
|
||||||
|
input[25] = static_cast<char>(0x96); // continuation - past the cap
|
||||||
|
input[26] = '\0';
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User u = makeUser(input, "u8");
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||||
|
// 24 bytes survive plus NUL. sanitizeUtf8 should have turned the dangling
|
||||||
|
// multi-byte head into '?' since its continuation bytes were chopped.
|
||||||
|
TEST_ASSERT_EQUAL_INT(24, (int)strlen(lite.long_name));
|
||||||
|
for (int i = 0; i < 22; ++i)
|
||||||
|
TEST_ASSERT_EQUAL_INT('a', lite.long_name[i]);
|
||||||
|
// The 4-byte sequence got truncated mid-codepoint; sanitizeUtf8 replaces
|
||||||
|
// any invalid lead/continuation byte with '?'.
|
||||||
|
TEST_ASSERT_EQUAL_INT('?', lite.long_name[22]);
|
||||||
|
TEST_ASSERT_EQUAL_INT('?', lite.long_name[23]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- short_name truncation --------------------------------------------
|
||||||
|
|
||||||
|
void test_short_name_passes_through(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User u = makeUser("Test", "abcd"); // 4 chars, fits short_name[5]
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("abcd", lite.short_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_short_name_truncates_when_too_long(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User u = makeUser("Test", "abcdefgh");
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||||
|
TEST_ASSERT_EQUAL_INT(4, (int)strlen(lite.short_name));
|
||||||
|
TEST_ASSERT_EQUAL_INT('\0', lite.short_name[4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- bitfield collapse + round-trip per bool --------------------------
|
||||||
|
|
||||||
|
void test_bitfield_is_licensed_round_trip(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
in.is_licensed = true;
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_LICENSED_MASK) != 0);
|
||||||
|
meshtastic_User out = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_TRUE(out.is_licensed);
|
||||||
|
|
||||||
|
in.is_licensed = false;
|
||||||
|
meshtastic_NodeInfoLite lite2 = meshtastic_NodeInfoLite_init_default;
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite2, in);
|
||||||
|
TEST_ASSERT_FALSE((lite2.bitfield & NODEINFO_BITFIELD_IS_LICENSED_MASK) != 0);
|
||||||
|
meshtastic_User out2 = TypeConversions::ConvertToUser(&lite2);
|
||||||
|
TEST_ASSERT_FALSE(out2.is_licensed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitfield_unmessagable_present_and_true(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
in.has_is_unmessagable = true;
|
||||||
|
in.is_unmessagable = true;
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK) != 0);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK) != 0);
|
||||||
|
meshtastic_User out = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_TRUE(out.has_is_unmessagable);
|
||||||
|
TEST_ASSERT_TRUE(out.is_unmessagable);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitfield_unmessagable_present_but_false(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
in.has_is_unmessagable = true;
|
||||||
|
in.is_unmessagable = false;
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK) != 0);
|
||||||
|
TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK) != 0);
|
||||||
|
meshtastic_User out = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_TRUE(out.has_is_unmessagable);
|
||||||
|
TEST_ASSERT_FALSE(out.is_unmessagable);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitfield_unmessagable_absent(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
in.has_is_unmessagable = false;
|
||||||
|
in.is_unmessagable = true; // explicitly true to make sure absence still wins
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK) != 0);
|
||||||
|
TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK) != 0);
|
||||||
|
meshtastic_User out = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_FALSE(out.has_is_unmessagable);
|
||||||
|
TEST_ASSERT_FALSE(out.is_unmessagable);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_copy_user_preserves_unrelated_bits(void)
|
||||||
|
{
|
||||||
|
// Pre-set is_muted and is_key_manually_verified - CopyUserToNodeInfoLite
|
||||||
|
// must not stomp them.
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.bitfield = NODEINFO_BITFIELD_IS_MUTED_MASK | NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK |
|
||||||
|
NODEINFO_BITFIELD_VIA_MQTT_MASK | NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK) != 0);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_VIA_MQTT_MASK) != 0);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_FAVORITE_MASK) != 0);
|
||||||
|
TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_IGNORED_MASK) != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitfield_bits_are_independent(void)
|
||||||
|
{
|
||||||
|
// Set just is_licensed, verify only that bit (and HAS_USER) is set.
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
in.is_licensed = true;
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
const uint32_t expected = NODEINFO_BITFIELD_HAS_USER_MASK | NODEINFO_BITFIELD_IS_LICENSED_MASK;
|
||||||
|
TEST_ASSERT_EQUAL_HEX32(expected, lite.bitfield);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- public_key / hw_model / role pass-through ------------------------
|
||||||
|
|
||||||
|
void test_public_key_round_trip(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
in.public_key.size = 32;
|
||||||
|
for (int i = 0; i < 32; ++i)
|
||||||
|
in.public_key.bytes[i] = (uint8_t)(i ^ 0xA5);
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
TEST_ASSERT_EQUAL_INT(32, lite.public_key.size);
|
||||||
|
TEST_ASSERT_EQUAL_MEMORY(in.public_key.bytes, lite.public_key.bytes, 32);
|
||||||
|
meshtastic_User out = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_EQUAL_INT(32, out.public_key.size);
|
||||||
|
TEST_ASSERT_EQUAL_MEMORY(in.public_key.bytes, out.public_key.bytes, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_hw_model_and_role_round_trip(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
meshtastic_User in = makeUser("a", "a");
|
||||||
|
in.hw_model = meshtastic_HardwareModel_HELTEC_V3;
|
||||||
|
in.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||||
|
TypeConversions::CopyUserToNodeInfoLite(&lite, in);
|
||||||
|
TEST_ASSERT_EQUAL_INT(meshtastic_HardwareModel_HELTEC_V3, lite.hw_model);
|
||||||
|
TEST_ASSERT_EQUAL_INT(meshtastic_Config_DeviceConfig_Role_ROUTER, lite.role);
|
||||||
|
meshtastic_User out = TypeConversions::ConvertToUser(&lite);
|
||||||
|
TEST_ASSERT_EQUAL_INT(meshtastic_HardwareModel_HELTEC_V3, out.hw_model);
|
||||||
|
TEST_ASSERT_EQUAL_INT(meshtastic_Config_DeviceConfig_Role_ROUTER, out.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ConvertToNodeInfo (3-arg) ----------------------------------------
|
||||||
|
|
||||||
|
void test_convert_to_node_info_thin_omits_position_and_metrics(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0xAA;
|
||||||
|
meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfoThin(&lite);
|
||||||
|
TEST_ASSERT_FALSE(info.has_position);
|
||||||
|
TEST_ASSERT_FALSE(info.has_device_metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_node_info_3arg_with_position(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0xAA;
|
||||||
|
meshtastic_PositionLite pos = meshtastic_PositionLite_init_default;
|
||||||
|
pos.latitude_i = 374200000;
|
||||||
|
pos.longitude_i = -1221000000;
|
||||||
|
pos.altitude = 30;
|
||||||
|
pos.time = 12345;
|
||||||
|
meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, &pos, nullptr);
|
||||||
|
TEST_ASSERT_TRUE(info.has_position);
|
||||||
|
TEST_ASSERT_FALSE(info.has_device_metrics);
|
||||||
|
TEST_ASSERT_EQUAL_INT32(374200000, info.position.latitude_i);
|
||||||
|
TEST_ASSERT_EQUAL_INT32(-1221000000, info.position.longitude_i);
|
||||||
|
TEST_ASSERT_EQUAL_INT32(30, info.position.altitude);
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(12345, info.position.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_node_info_3arg_with_metrics(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0xAA;
|
||||||
|
meshtastic_DeviceMetrics dm = meshtastic_DeviceMetrics_init_default;
|
||||||
|
dm.battery_level = 88;
|
||||||
|
dm.has_battery_level = true;
|
||||||
|
dm.voltage = 3.71f;
|
||||||
|
dm.has_voltage = true;
|
||||||
|
meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, &dm);
|
||||||
|
TEST_ASSERT_FALSE(info.has_position);
|
||||||
|
TEST_ASSERT_TRUE(info.has_device_metrics);
|
||||||
|
TEST_ASSERT_EQUAL_INT(88, info.device_metrics.battery_level);
|
||||||
|
TEST_ASSERT_TRUE(info.device_metrics.has_battery_level);
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(3.71f, info.device_metrics.voltage);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_node_info_3arg_null_inputs(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0xAA;
|
||||||
|
meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr);
|
||||||
|
TEST_ASSERT_FALSE(info.has_position);
|
||||||
|
TEST_ASSERT_FALSE(info.has_device_metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_node_info_extracts_bitfield_bools(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0xBB;
|
||||||
|
lite.bitfield = NODEINFO_BITFIELD_VIA_MQTT_MASK | NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
|
||||||
|
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK | NODEINFO_BITFIELD_IS_MUTED_MASK;
|
||||||
|
meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr);
|
||||||
|
TEST_ASSERT_TRUE(info.via_mqtt);
|
||||||
|
TEST_ASSERT_TRUE(info.is_favorite);
|
||||||
|
TEST_ASSERT_TRUE(info.is_ignored);
|
||||||
|
TEST_ASSERT_TRUE(info.is_key_manually_verified);
|
||||||
|
TEST_ASSERT_TRUE(info.is_muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_node_info_extracts_bitfield_bools_none_set(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0xBB;
|
||||||
|
lite.bitfield = 0;
|
||||||
|
meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr);
|
||||||
|
TEST_ASSERT_FALSE(info.via_mqtt);
|
||||||
|
TEST_ASSERT_FALSE(info.is_favorite);
|
||||||
|
TEST_ASSERT_FALSE(info.is_ignored);
|
||||||
|
TEST_ASSERT_FALSE(info.is_key_manually_verified);
|
||||||
|
TEST_ASSERT_FALSE(info.is_muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_convert_to_node_info_user_only_when_has_user_bit_set(void)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||||
|
lite.num = 0x01;
|
||||||
|
strcpy(lite.long_name, "Alpha");
|
||||||
|
strcpy(lite.short_name, "A");
|
||||||
|
// No HAS_USER bit -> user fields ignored on emit.
|
||||||
|
meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr);
|
||||||
|
TEST_ASSERT_FALSE(info.has_user);
|
||||||
|
|
||||||
|
lite.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||||||
|
meshtastic_NodeInfo info2 = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr);
|
||||||
|
TEST_ASSERT_TRUE(info2.has_user);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Alpha", info2.user.long_name);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("A", info2.user.short_name);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("!00000001", info2.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- entry point -------------------------------------------------------
|
||||||
|
|
||||||
|
void setup()
|
||||||
|
{
|
||||||
|
delay(10);
|
||||||
|
delay(2000);
|
||||||
|
initializeTestEnvironment();
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_copy_user_sets_has_user_bit);
|
||||||
|
RUN_TEST(test_convert_to_user_id_derived_from_nodenum);
|
||||||
|
RUN_TEST(test_convert_to_user_zero_fills_macaddr);
|
||||||
|
RUN_TEST(test_long_name_short_passes_through);
|
||||||
|
RUN_TEST(test_long_name_exact_24_fits);
|
||||||
|
RUN_TEST(test_long_name_truncates_when_too_long);
|
||||||
|
RUN_TEST(test_long_name_round_trip_to_wire);
|
||||||
|
RUN_TEST(test_long_name_truncated_utf8_boundary_sanitized);
|
||||||
|
RUN_TEST(test_short_name_passes_through);
|
||||||
|
RUN_TEST(test_short_name_truncates_when_too_long);
|
||||||
|
RUN_TEST(test_bitfield_is_licensed_round_trip);
|
||||||
|
RUN_TEST(test_bitfield_unmessagable_present_and_true);
|
||||||
|
RUN_TEST(test_bitfield_unmessagable_present_but_false);
|
||||||
|
RUN_TEST(test_bitfield_unmessagable_absent);
|
||||||
|
RUN_TEST(test_copy_user_preserves_unrelated_bits);
|
||||||
|
RUN_TEST(test_bitfield_bits_are_independent);
|
||||||
|
RUN_TEST(test_public_key_round_trip);
|
||||||
|
RUN_TEST(test_hw_model_and_role_round_trip);
|
||||||
|
RUN_TEST(test_convert_to_node_info_thin_omits_position_and_metrics);
|
||||||
|
RUN_TEST(test_convert_to_node_info_3arg_with_position);
|
||||||
|
RUN_TEST(test_convert_to_node_info_3arg_with_metrics);
|
||||||
|
RUN_TEST(test_convert_to_node_info_3arg_null_inputs);
|
||||||
|
RUN_TEST(test_convert_to_node_info_extracts_bitfield_bools);
|
||||||
|
RUN_TEST(test_convert_to_node_info_extracts_bitfield_bools_none_set);
|
||||||
|
RUN_TEST(test_convert_to_node_info_user_only_when_has_user_bit_set);
|
||||||
|
exit(UNITY_END());
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#ifndef Pins_Arduino_h
|
||||||
|
#define Pins_Arduino_h
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#define USB_VID 0x303a
|
||||||
|
#define USB_PID 0x1001
|
||||||
|
|
||||||
|
// The default Wire will be mapped to PMU and RTC
|
||||||
|
static const uint8_t SDA = 17;
|
||||||
|
static const uint8_t SCL = 18;
|
||||||
|
|
||||||
|
// Default SPI will be mapped to Radio
|
||||||
|
static const uint8_t SS = 39;
|
||||||
|
static const uint8_t MOSI = 40;
|
||||||
|
static const uint8_t MISO = 41;
|
||||||
|
static const uint8_t SCK = 42;
|
||||||
|
|
||||||
|
// #define SPI_MOSI (11)
|
||||||
|
// #define SPI_SCK (10)
|
||||||
|
// #define SPI_MISO (9)
|
||||||
|
// #define SPI_CS (12)
|
||||||
|
|
||||||
|
// #define SDCARD_CS SPI_CS
|
||||||
|
|
||||||
|
#endif /* Pins_Arduino_h */
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[env:thinknode_g3]
|
||||||
|
extends = esp32s3_base
|
||||||
|
board = ESP32-S3-WROOM-1-N4
|
||||||
|
board_build.psram_type = opi
|
||||||
|
|
||||||
|
build_flags =
|
||||||
|
${esp32s3_base.build_flags}
|
||||||
|
-D HAS_UDP_MULTICAST=1
|
||||||
|
-D BOARD_HAS_PSRAM
|
||||||
|
-D PRIVATE_HW
|
||||||
|
-I variants/esp32s3/ELECROW-ThinkNode-G3
|
||||||
|
-mfix-esp32-psram-cache-issue
|
||||||
|
|
||||||
|
lib_ignore =
|
||||||
|
Ethernet
|
||||||
|
|
||||||
|
lib_deps =
|
||||||
|
${esp32s3_base.lib_deps}
|
||||||
|
# renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390
|
||||||
|
https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.0.1.zip
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#define HAS_GPS 0
|
||||||
|
#define HAS_WIRE 0
|
||||||
|
#define I2C_NO_RESCAN
|
||||||
|
|
||||||
|
#define WIFI_LED 5
|
||||||
|
#define WIFI_STATE_ON 0
|
||||||
|
|
||||||
|
#define LED_PIN 6
|
||||||
|
#define LED_STATE_ON 0
|
||||||
|
#define BUTTON_PIN 4
|
||||||
|
|
||||||
|
#define LORA_SCK 42
|
||||||
|
#define LORA_MISO 41
|
||||||
|
#define LORA_MOSI 40
|
||||||
|
#define LORA_CS 39
|
||||||
|
#define LORA_RESET 21
|
||||||
|
|
||||||
|
#define USE_SX1262
|
||||||
|
#define SX126X_CS LORA_CS
|
||||||
|
#define SX126X_DIO1 15
|
||||||
|
#define SX126X_BUSY 47
|
||||||
|
#define SX126X_RESET LORA_RESET
|
||||||
|
#define SX126X_DIO2_AS_RF_SWITCH
|
||||||
|
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
|
||||||
|
#define PIN_POWER_EN 45
|
||||||
|
|
||||||
|
#define HAS_ETHERNET 1
|
||||||
|
#define USE_CH390D 1
|
||||||
|
|
||||||
|
#define ETH_MISO_PIN 12
|
||||||
|
#define ETH_MOSI_PIN 11
|
||||||
|
#define ETH_SCLK_PIN 13
|
||||||
|
#define ETH_CS_PIN 14
|
||||||
|
#define ETH_INT_PIN 10
|
||||||
|
#define ETH_RST_PIN 9
|
||||||
|
// #define ETH_ADDR 1
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user