Compare commits
157
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e1f9b5bc7 | ||
|
|
fd3002b9a3 | ||
|
|
25ac520fb8 | ||
|
|
d39bf2f8a0 | ||
|
|
94bb21ecc7 | ||
|
|
1bcabb893b | ||
|
|
fefd424901 | ||
|
|
f63716c322 | ||
|
|
5e2ca8aed4 | ||
|
|
4553d1e0b1 | ||
|
|
784e3748e2 | ||
|
|
b11d29ff31 | ||
|
|
cdc47a2aea | ||
|
|
fcef46f4b0 | ||
|
|
bb86cf4e81 | ||
|
|
6ea0d5ebba | ||
|
|
b2bda3b07a | ||
|
|
b7a9555905 | ||
|
|
1eb860a3fc | ||
|
|
989b8620ba | ||
|
|
a0951f23c3 | ||
|
|
e19f531059 | ||
|
|
6c7ffa1054 | ||
|
|
f037ce2165 | ||
|
|
bc17d004ab | ||
|
|
06a6c3ee20 | ||
|
|
bfadf0c36a | ||
|
|
24c4162a75 | ||
|
|
b148fac340 | ||
|
|
8dde4eeee1 | ||
|
|
4d4e14600c | ||
|
|
0bd8dee346 | ||
|
|
2828dbe4ca | ||
|
|
a8c8fd7002 | ||
|
|
e7c02da24b | ||
|
|
7421953e8f | ||
|
|
9306e66067 | ||
|
|
7adfc3f992 | ||
|
|
ba9cadc14d | ||
|
|
924411de59 | ||
|
|
d9195944df | ||
|
|
83a98c81f6 | ||
|
|
837637b70c | ||
|
|
7b3f58875a | ||
|
|
b2d980fc25 | ||
|
|
2ed7bba5e7 | ||
|
|
22d50fe437 | ||
|
|
cde5a08bc5 | ||
|
|
55bf8c25fc | ||
|
|
399dde0f4b | ||
|
|
66971a0a26 | ||
|
|
4c24218afb | ||
|
|
4b4914736f | ||
|
|
a6b1a69630 | ||
|
|
28e705de5c | ||
|
|
fcb9ec0c2d | ||
|
|
a4b55bc6f2 | ||
|
|
b53fe7a1e7 | ||
|
|
db9fdd6794 | ||
|
|
3b4c66439d | ||
|
|
945f4780ea | ||
|
|
0e38a15d46 | ||
|
|
63bce1f01a | ||
|
|
4090d9f2b3 | ||
|
|
d50caf231b | ||
|
|
f396200d38 | ||
|
|
6c04c37294 | ||
|
|
34aa5e995b | ||
|
|
c8dac10348 | ||
|
|
aab4cd086f | ||
|
|
6208c243f9 | ||
|
|
3a87e746a8 | ||
|
|
cd04206334 | ||
|
|
2768080edf | ||
|
|
466cc4cecd | ||
|
|
fe90c49795 | ||
|
|
31418ca821 | ||
|
|
0ee5777c15 | ||
|
|
026213aab7 | ||
|
|
0cab43fb43 | ||
|
|
1341cd4078 | ||
|
|
4059202a5c | ||
|
|
d24d8806e1 | ||
|
|
a67eb15ad3 | ||
|
|
9e182a595c | ||
|
|
4587dc2d64 | ||
|
|
00fccd87f9 | ||
|
|
01bd4cfb73 | ||
|
|
125c1b7f13 | ||
|
|
d6cf67b6bc | ||
|
|
77f378dd53 | ||
|
|
16dcafa7fb | ||
|
|
4ac74edf38 | ||
|
|
69495dcd98 | ||
|
|
288d7a3e7b | ||
|
|
4990bf51e3 | ||
|
|
b2c8cbb78d | ||
|
|
8061f83704 | ||
|
|
a3b49b9028 | ||
|
|
17945884a5 | ||
|
|
1116f06139 | ||
|
|
c728cfdaf4 | ||
|
|
9322bcdb21 | ||
|
|
2f19a1d7a4 | ||
|
|
71c8143f72 | ||
|
|
222d3e6b62 | ||
|
|
2e6519bb98 | ||
|
|
726d539174 | ||
|
|
e7ee4bea18 | ||
|
|
efd2613bd7 | ||
|
|
f88bc732cc | ||
|
|
0746f8cfff | ||
|
|
0ac95e370b | ||
|
|
283ccb83d1 | ||
|
|
5319bc7c2c | ||
|
|
db694f2f24 | ||
|
|
814f1289f6 | ||
|
|
0b7556b590 | ||
|
|
f7e4ac3e43 | ||
|
|
c36ae159ed | ||
|
|
33e7f16c05 | ||
|
|
d3a86841b9 | ||
|
|
e14b8d385a | ||
|
|
8ce1a872eb | ||
|
|
abfa346630 | ||
|
|
2ffe2d829c | ||
|
|
bfaf6c6b20 | ||
|
|
d293d654a0 | ||
|
|
8384659608 | ||
|
|
a632d0133f | ||
|
|
f982b58d61 | ||
|
|
efb0b299b2 | ||
|
|
eb80d3141d | ||
|
|
3604c1255d | ||
|
|
22d63fa69c | ||
|
|
fc030d2d19 | ||
|
|
4a534f02a4 | ||
|
|
0ea408e12b | ||
|
|
644d0d4a15 | ||
|
|
f04746a928 | ||
|
|
1be2529fb9 | ||
|
|
959756abf1 | ||
|
|
c88b802e32 | ||
|
|
9f74fc11de | ||
|
|
2ef09d17b9 | ||
|
|
19d070c284 | ||
|
|
e24db2994b | ||
|
|
ac7a58cd45 | ||
|
|
53c21eb30d | ||
|
|
e51e6cad84 | ||
|
|
4890f7084f | ||
|
|
3fcbfe4370 | ||
|
|
da808cb43b | ||
|
|
82580c6798 | ||
|
|
016e68ec53 | ||
|
|
79f469ce71 | ||
|
|
0501e177e9 |
@@ -49,17 +49,11 @@ 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 primary channel NAMES match? Mismatch = different PSK = no decode.
|
||||
|
||||
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**:
|
||||
7. **Suggest next actions only for specific, recognisable failure modes**:
|
||||
- Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path."
|
||||
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
|
||||
- Device unreachable, 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`.
|
||||
- Heap slope strongly negative → "run `/leakhunt window=6h` for a full timeline + classification."
|
||||
|
||||
## What NOT to do
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
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,8 +3,6 @@ description: Re-run a specific test N times in isolation to triage flakes, diff
|
||||
argument-hint: <test-node-id> [count=5]
|
||||
---
|
||||
|
||||
<!-- markdownlint-disable MD029 -->
|
||||
|
||||
# `/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."
|
||||
@@ -42,8 +40,6 @@ 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.
|
||||
|
||||
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:
|
||||
- **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.
|
||||
|
||||
@@ -8,21 +8,17 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/python:1": {
|
||||
"installTools": true,
|
||||
"version": "3.13"
|
||||
"version": "3.14"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-vscode.cpptools",
|
||||
"Jason2866.esp-decoder",
|
||||
"pioarduino.pioarduino-ide",
|
||||
"platformio.platformio-ide",
|
||||
"Trunk.io"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-azuretools.vscode-docker",
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": ["ms-azuretools.vscode-docker"],
|
||||
"settings": {
|
||||
"extensions.ignoreRecommendations": true
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ runs:
|
||||
done
|
||||
|
||||
- name: PlatformIO ${{ inputs.arch }} download cache
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-cache-${{ inputs.arch }}-${{ hashFiles('.github/actions/**', '**.ini') }}
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **`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
|
||||
|
||||
```
|
||||
@@ -196,8 +285,6 @@ firmware/
|
||||
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
||||
- Use `assert()` for invariants that should never fail
|
||||
- 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
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
path: meshtasticd
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
outputs:
|
||||
artifact-id: ${{ steps.upload-firmware.outputs.artifact-id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: macos-${{ inputs.macos_ver }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -4,9 +4,14 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# trunk-ignore(checkov/CKV_GHA_7)
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
arch:
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
@@ -15,35 +20,21 @@ on:
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
# find-target:
|
||||
# type: boolean
|
||||
# default: true
|
||||
# description: 'Find the available targets'
|
||||
description: Choose an arch to limit the search, or 'all' to search all architectures.
|
||||
default: all
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
find-targets:
|
||||
if: ${{ inputs.target == '' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
- esp32c6
|
||||
- nrf52840
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
- all
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
@@ -51,20 +42,43 @@ jobs:
|
||||
- run: pip install -U platformio
|
||||
- name: Generate matrix
|
||||
id: jsonStep
|
||||
env:
|
||||
BUILDTARGET: ${{ inputs.target }}
|
||||
MATRIXARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Targets:" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$BUILDTARGET" = "" ]; then
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "We build this one:" >> $GITHUB_STEP_SUMMARY
|
||||
ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform')
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "$ARCH" == "" ]]; then
|
||||
echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY
|
||||
echo "arch=$ARCH" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
outputs:
|
||||
arch: ${{ steps.jsonStep.outputs.arch }}
|
||||
|
||||
version:
|
||||
if: ${{ inputs.target != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get release version string
|
||||
run: |
|
||||
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
@@ -78,12 +92,12 @@ jobs:
|
||||
|
||||
build:
|
||||
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
||||
needs: [version]
|
||||
needs: [version, find-targets]
|
||||
uses: ./.github/workflows/build_firmware.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.long }}
|
||||
pio_env: ${{ inputs.target }}
|
||||
platform: ${{ inputs.arch }}
|
||||
platform: ${{ needs.find-targets.outputs.arch }}
|
||||
|
||||
gather-artifacts:
|
||||
permissions:
|
||||
@@ -93,7 +107,7 @@ jobs:
|
||||
needs: [version, build]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{github.event.pull_request.head.ref}}
|
||||
repository: ${{github.event.pull_request.head.repo.full_name}}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
name: Post Web Flasher Link Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-flasher-link:
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Per-board manifests carry the firmware's own metadata (activelySupported,
|
||||
# displayName, ...) generated from each target's custom_meshtastic_* config.
|
||||
- name: Download board manifests
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: manifest-*
|
||||
path: ./manifests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Post or update web flasher link comment
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const run = context.payload.workflow_run;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Resolve the PR number (run.pull_requests is empty for fork PRs)
|
||||
let prNumber = run.pull_requests?.[0]?.number;
|
||||
if (!prNumber) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner, repo, commit_sha: run.head_sha,
|
||||
});
|
||||
prNumber = (prs.find((pr) => pr.head.sha === run.head_sha) ?? prs[0])?.number;
|
||||
}
|
||||
if (!prNumber) {
|
||||
core.info('No pull request associated with this run; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
|
||||
// Only comment on PRs authored by members of the organization.
|
||||
// author_association MEMBER is computed by GitHub and reflects org
|
||||
// membership (including concealed members); OWNER covers a repo owner.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not an org member; skipping.`);
|
||||
return;
|
||||
}
|
||||
if (pr.state !== 'open') {
|
||||
core.info('Pull request is not open; skipping.');
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== run.head_sha) {
|
||||
core.info('Run is for an outdated commit; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Require at least one per-arch firmware artifact from gather-artifacts
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner, repo, run_id: run.id, per_page: 100,
|
||||
});
|
||||
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
|
||||
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
|
||||
if (archArtifacts.length === 0) {
|
||||
core.info('No per-arch firmware artifacts found; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = archRe.exec(archArtifacts[0].name)[2];
|
||||
const expiresAt = archArtifacts[0].expires_at
|
||||
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
|
||||
: null;
|
||||
|
||||
// Read each built board's manifest (.mt.json). activelySupported,
|
||||
// displayName and architecture come straight from the board's
|
||||
// custom_meshtastic_* platformio config, so the list is in sync with
|
||||
// the firmware itself — no external device database needed.
|
||||
const fs = require('fs');
|
||||
let boards = [];
|
||||
try {
|
||||
boards = fs.readdirSync('./manifests')
|
||||
.filter((f) => f.endsWith('.mt.json'))
|
||||
.map((f) => {
|
||||
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
|
||||
.map((m) => ({
|
||||
board: m.platformioTarget,
|
||||
platform: m.architecture || '',
|
||||
// displayName is maintainer-authored text; escape table-breaking pipes
|
||||
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
|
||||
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
|
||||
}))
|
||||
.sort((a, b) => a.board.localeCompare(b.board));
|
||||
} catch (e) {
|
||||
core.warning(`Could not read board manifests: ${e.message}`);
|
||||
}
|
||||
|
||||
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
|
||||
// Device illustrations are served by the flasher from the same image
|
||||
// names the manifest declares (custom_meshtastic_images). The flasher
|
||||
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
|
||||
// image really resolves to an image before linking it.
|
||||
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
|
||||
await Promise.all(boards.map(async (b) => {
|
||||
if (!b.image) return;
|
||||
try {
|
||||
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
|
||||
const type = res.headers.get('content-type') || '';
|
||||
if (!res.ok || !type.startsWith('image/')) b.image = '';
|
||||
} catch { b.image = ''; }
|
||||
}));
|
||||
|
||||
const boardLines = boards
|
||||
.map((b) => {
|
||||
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
|
||||
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Shields.io badges. Only non-user-controlled, charset-constrained values
|
||||
// (version, commit sha, counts, dates) go into badge URLs — never board
|
||||
// names or the PR title — so the rendered comment cannot be spoofed.
|
||||
const shieldText = (s) =>
|
||||
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
|
||||
const shield = (label, message, color) =>
|
||||
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
|
||||
const buttonUrl =
|
||||
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
|
||||
const badges = [
|
||||
`})`,
|
||||
`, '2C2D3C')})`,
|
||||
`})`,
|
||||
];
|
||||
if (expiresAt) badges.push(`})`);
|
||||
|
||||
// Only render the board table when there are supported boards to list
|
||||
const boardTable = boards.length > 0 ? [
|
||||
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
|
||||
'',
|
||||
'| | Device | Board | Platform |',
|
||||
'| --- | --- | --- | --- |',
|
||||
boardLines,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
] : [];
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
`[](${flasherUrl})`,
|
||||
'',
|
||||
badges.join(' '),
|
||||
'',
|
||||
'> [!WARNING]',
|
||||
'> This is an automated, unreviewed CI test build. Back up your device configuration',
|
||||
'> before flashing, and only flash devices you are able to recover.',
|
||||
'',
|
||||
...boardTable,
|
||||
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
|
||||
].join('\n');
|
||||
|
||||
// Sticky comment: update in place when the marker is found
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Post Web Flasher Build Placeholder
|
||||
|
||||
# Drops an immediate "build in progress" comment when a PR opens, so the web
|
||||
# flasher entry shows up right away. The real CI-driven workflow
|
||||
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
|
||||
#
|
||||
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
|
||||
# safe because it never checks out or runs PR code and posts a fully static body
|
||||
# — no PR title, branch name, or other untrusted input is used anywhere.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
post-placeholder:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post web flasher build-in-progress placeholder
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Only org members get the flasher comment (matches the real workflow)
|
||||
const allowedAssociations = ['OWNER', 'MEMBER'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not an org member; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only seed a placeholder when no flasher comment exists yet — never
|
||||
// overwrite a real (or existing placeholder) comment.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
if (comments.some((c) => c.body?.includes(marker))) {
|
||||
core.info('Flasher comment already exists; nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
'> [!NOTE]',
|
||||
'> Building this pull request… the flash button, badges and supported-board',
|
||||
'> list will appear here automatically once CI finishes.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number, body,
|
||||
});
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- check
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get release version string
|
||||
run: |
|
||||
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Check ${{ matrix.check.board }}
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
needs: [version, build]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -245,6 +245,48 @@ jobs:
|
||||
path: ./*.elf
|
||||
retention-days: 30
|
||||
|
||||
shame:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
|
||||
fetch-depth: 0
|
||||
- name: Download the current manifests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./manifests-new/
|
||||
pattern: manifest-*
|
||||
merge-multiple: true
|
||||
- name: Upload combined manifests for later commit and global stats crunching.
|
||||
uses: actions/upload-artifact@v7
|
||||
id: upload-manifest
|
||||
with:
|
||||
name: manifests-${{ github.sha }}
|
||||
overwrite: true
|
||||
path: manifests-new/*.mt.json
|
||||
- name: Find the merge base
|
||||
if: github.event_name == 'pull_request'
|
||||
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
|
||||
env:
|
||||
base: ${{ github.base_ref }}
|
||||
head: ${{ github.sha }}
|
||||
# Currently broken (for-loop through EVERY artifact -- rate limiting)
|
||||
# - name: Download the old manifests
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
|
||||
# env:
|
||||
# GH_TOKEN: ${{ github.token }}
|
||||
# merge_base: ${{ env.MERGE_BASE }}
|
||||
# repo: ${{ github.repository }}
|
||||
# - name: Do scan and post comment
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
|
||||
|
||||
release-artifacts:
|
||||
permissions: # Needed for 'gh release upload'.
|
||||
contents: write
|
||||
@@ -261,7 +303,7 @@ jobs:
|
||||
# - MacOS
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -291,7 +333,6 @@ jobs:
|
||||
prerelease: true
|
||||
name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
|
||||
tag_name: v${{ needs.version.outputs.long }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
body: ${{ steps.release_notes.outputs.notes }}
|
||||
|
||||
- name: Download source deb
|
||||
@@ -362,7 +403,7 @@ jobs:
|
||||
needs: [release-artifacts, version]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -417,7 +458,7 @@ jobs:
|
||||
esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1.3.1
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
trunk-token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
trunk_upgrade:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/main/readme.md#automatic-upgrades
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/v1/readme.md#automatic-upgrades
|
||||
name: Trunk Upgrade (PR)
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
pull-requests: write # For trunk to create PRs
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Upgrade
|
||||
uses: trunk-io/trunk-action/upgrade@v1.3.1
|
||||
uses: trunk-io/trunk-action/upgrade@v1
|
||||
with:
|
||||
base: master
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
needs: build-debian-src
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
path: meshtasticd
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
needs: build-debian-src
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
path: meshtasticd
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Always use master branch for version bumps
|
||||
ref: master
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
steps:
|
||||
# step 1
|
||||
- name: clone application source code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# step 2
|
||||
- name: full scan
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
# step 1
|
||||
- name: clone application source code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Stale PR+Issues
|
||||
uses: actions/stale@v10.3.0
|
||||
uses: actions/stale@v10.2.0
|
||||
with:
|
||||
days-before-stale: 45
|
||||
stale-issue-message: This issue has not had any comment or update in the last month. If it is still relevant, please post update comments. If no comments are made, this issue will be closed automagically in 7 days.
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: Native Simulator Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
name: Native PlatformIO Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -86,7 +86,13 @@ jobs:
|
||||
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
||||
|
||||
- name: PlatformIO Tests
|
||||
run: platformio test -e coverage -v --junit-output-path testreport.xml
|
||||
run: |
|
||||
set -o pipefail
|
||||
# Filter out SKIPPED summary rows for hardware variants that can't run on the
|
||||
# native host. They flood the log and make it harder to spot real failures.
|
||||
# The JUnit XML is written directly to testreport.xml before the pipe, so
|
||||
# the test artifact is unaffected.
|
||||
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
|
||||
|
||||
- name: Save test results
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -123,7 +129,7 @@ jobs:
|
||||
- platformio-tests
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Get release version string
|
||||
run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: test-runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# - uses: actions/setup-python@v6
|
||||
# with:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Annotate PR with trunk issues
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/main/readme.md#getting-inline-annotations-for-fork-prs
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/v1/readme.md#getting-inline-annotations-for-fork-prs
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1.3.1
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
post-annotations: true
|
||||
|
||||
@@ -16,9 +16,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1.3.1
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
save-annotations: true
|
||||
|
||||
@@ -11,23 +11,18 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Update submodule
|
||||
if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }}
|
||||
working-directory: protobufs
|
||||
env:
|
||||
# Use the branch that triggered the workflow as the protobuf branch.
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
|
||||
run: |
|
||||
git fetch --prune origin $GIT_BRANCH
|
||||
git checkout origin/$GIT_BRANCH
|
||||
git submodule update --remote protobufs
|
||||
|
||||
- name: Download nanopb
|
||||
run: |
|
||||
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9
|
||||
|
||||
@@ -38,7 +33,7 @@ jobs:
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
|
||||
branch: create-pull-request/update-protobufs
|
||||
labels: submodules
|
||||
title: Update protobufs and classes
|
||||
commit-message: Update protobufs
|
||||
|
||||
+9
-9
@@ -4,21 +4,21 @@ cli:
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.10.2
|
||||
ref: v1.8.0
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
enabled:
|
||||
- checkov@3.3.1
|
||||
- renovate@43.236.0
|
||||
- prettier@3.8.4
|
||||
- trufflehog@3.95.6
|
||||
- checkov@3.2.526
|
||||
- renovate@43.150.0
|
||||
- prettier@3.8.3
|
||||
- trufflehog@3.95.2
|
||||
- yamllint@1.38.0
|
||||
- bandit@1.9.4
|
||||
- trivy@0.71.2
|
||||
- trivy@0.70.0
|
||||
- taplo@0.10.0
|
||||
- ruff@0.15.18
|
||||
- ruff@0.15.12
|
||||
- isort@8.0.1
|
||||
- markdownlint@0.49.0
|
||||
- markdownlint@0.48.0
|
||||
- oxipng@10.1.1
|
||||
- svgo@4.0.1
|
||||
- actionlint@1.7.12
|
||||
@@ -26,7 +26,7 @@ lint:
|
||||
- hadolint@2.14.0
|
||||
- shfmt@3.6.0
|
||||
- shellcheck@0.11.0
|
||||
- black@26.5.1
|
||||
- black@26.3.1
|
||||
- git-diff-check
|
||||
- gitleaks@8.30.1
|
||||
- clang-format@16.0.3
|
||||
|
||||
Vendored
+4
-4
@@ -1,10 +1,10 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"Jason2866.esp-decoder",
|
||||
"pioarduino.pioarduino-ide"
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack",
|
||||
"platformio.platformio-ide"
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@ 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
# Ensure the Alpine version is updated in both stages of the container!
|
||||
FROM alpine:3.24 AS builder
|
||||
FROM alpine:3.23 AS builder
|
||||
ARG PIO_ENV=native
|
||||
|
||||
# Enable Alpine community repository (for 'py3-grpcio-tools')
|
||||
@@ -35,7 +35,7 @@ RUN bash ./bin/build-native.sh "$PIO_ENV" && \
|
||||
|
||||
# ##### PRODUCTION BUILD #############
|
||||
|
||||
FROM alpine:3.24
|
||||
FROM alpine:3.23
|
||||
LABEL org.opencontainers.image.title="Meshtastic" \
|
||||
org.opencontainers.image.description="Alpine Meshtastic daemon" \
|
||||
org.opencontainers.image.url="https://meshtastic.org" \
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, HIGH, LOW]
|
||||
MODE_TX: [HIGH, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
MODE_TX_HF: [LOW, LOW, LOW]
|
||||
MODE_GNSS: [LOW, LOW, HIGH]
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins:
|
||||
- DIO5
|
||||
- DIO6
|
||||
MODE_STBY:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_RX:
|
||||
- HIGH
|
||||
- LOW
|
||||
MODE_TX:
|
||||
- HIGH
|
||||
- HIGH
|
||||
MODE_TX_HP:
|
||||
- LOW
|
||||
- HIGH
|
||||
MODE_TX_HF:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_GNSS:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_WIFI:
|
||||
- LOW
|
||||
- LOW
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, LOW, LOW]
|
||||
MODE_TX: [LOW, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
# MODE_TX_HF: []
|
||||
# MODE_GNSS: []
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,19 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
|
||||
# Use for 1 watt hat
|
||||
Meta:
|
||||
name: NebraHat 1W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Nebra SX1262 Pi Hat - 1W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
|
||||
IRQ: 22
|
||||
Busy: 4
|
||||
Reset: 18
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -1,20 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
|
||||
# Use for 2 watt hat
|
||||
Meta:
|
||||
name: NebraHat 2W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Nebra SX1262 Pi Hat - 2W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 8
|
||||
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
|
||||
IRQ: 22
|
||||
Busy: 4
|
||||
Reset: 18
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -18,4 +18,4 @@ Lora:
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: 8
|
||||
# CS: 8
|
||||
@@ -5,9 +5,7 @@ Meta:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
|
||||
### RAK13300 in Slot 2
|
||||
Module: sx1262
|
||||
### RAK13300 in Slot 2 pins
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
@@ -15,7 +13,5 @@ Lora:
|
||||
Enable_Pins:
|
||||
- 26
|
||||
- 23
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
# CS: 7
|
||||
@@ -5,18 +5,14 @@ Meta:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
|
||||
### RAK13302 in Slot 2
|
||||
Module: sx1262
|
||||
### RAK13302 in Slot 2 pins
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
# Ant_sw: 23 # IO3
|
||||
# Ant_sw: 23 # IO3
|
||||
Enable_Pins:
|
||||
- 26
|
||||
- 23
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -1,19 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
|
||||
# Use for 1 watt hat
|
||||
Meta:
|
||||
name: ZebraHat 1W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Zebra SX1262 Pi Hat - 1W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 18
|
||||
CS: 24
|
||||
IRQ: 22
|
||||
Busy: 27
|
||||
Reset: 17
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -1,20 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
|
||||
# Use for 2 watt hat
|
||||
Meta:
|
||||
name: ZebraHat 2W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Zebra SX1262 Pi Hat - 2W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 8
|
||||
CS: 24
|
||||
IRQ: 22
|
||||
Busy: 27
|
||||
Reset: 17
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Station G3 + BQ35LORA900V1M Primary Slot
|
||||
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
|
||||
Meta:
|
||||
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
|
||||
support: official
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_A5 (physical 15)
|
||||
pin: 5
|
||||
gpiochip: 0
|
||||
line: 5
|
||||
Reset: # GPIO1_D1 (physical 36)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_B4 (physical 18)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_B2 (physical 24)
|
||||
# pin: 10
|
||||
# gpiochip: 0
|
||||
# line: 10
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
|
||||
@@ -31,8 +31,6 @@ Lora:
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
|
||||
@@ -31,8 +31,6 @@ Lora:
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Station G3 + BQ35LORA900V1M Primary Slot
|
||||
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
|
||||
Meta:
|
||||
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: 22
|
||||
Reset: 16
|
||||
Busy: 24
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
#CS: 8
|
||||
@@ -1,18 +0,0 @@
|
||||
# Station G3 motherboard with a Raspberry Pi Zero 2W as the MCU daughterboard.
|
||||
# Verify spidev / I2C device paths for your OS — they may differ.
|
||||
Meta:
|
||||
name: Station G3
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: 22 # BCM pin — wiki spec
|
||||
Reset: 16 # BCM pin — wiki spec
|
||||
Busy: 24 # BCM pin — wiki spec
|
||||
# CS: 8 # BCM 8 = SPI0 CE0 (default); uncomment only to override
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
spidev: spidev0.0
|
||||
# SX126X_MAX_POWER: 19 # matches Station G2 firmware cap; raise carefully per PA jumper mode
|
||||
@@ -1,5 +1,3 @@
|
||||
# This config works with all revisions of the Meshtoad USB radio.
|
||||
|
||||
Meta:
|
||||
name: meshtoad-e22
|
||||
support: official
|
||||
|
||||
@@ -87,15 +87,6 @@
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="2.7.27" date="2026-06-24">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.27</url>
|
||||
</release>
|
||||
<release version="2.7.26" date="2026-06-10">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.26</url>
|
||||
</release>
|
||||
<release version="2.7.25" date="2026-05-23">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.25</url>
|
||||
</release>
|
||||
<release version="2.7.24" date="2026-05-08">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24</url>
|
||||
</release>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x4405"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x2886", "0x1667"]
|
||||
],
|
||||
"usb_product": "HT-n5262",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "heltec_mesh_node_t1",
|
||||
"variants_dir": "variants",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": ["jlink"],
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Heltec Mesh Node T1",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://heltec.org",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=0"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "station-g3"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "BQ Station G3",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "",
|
||||
"vendor": "BQ Consulting"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DARDUINO_NRF52840_T_IMPULSE_PLUS -DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [["0x239A", "0x8029"]],
|
||||
"usb_product": "T-Impulse-Plus-nRF52840",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "t-impulse-plus",
|
||||
"variants_dir": "variants",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": ["jlink"],
|
||||
"svd_path": "nrf52840.svd"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Lilygo T-Impulse-Plus-nRF52840",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"require_upload_port": true,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": [
|
||||
"jlink",
|
||||
"nrfjprog",
|
||||
"nrfutil",
|
||||
"stlink",
|
||||
"cmsis-dap",
|
||||
"blackmagic"
|
||||
]
|
||||
},
|
||||
"url": "https://www.lilygo.cc/",
|
||||
"vendor": "Lilygo"
|
||||
}
|
||||
Vendored
-18
@@ -1,21 +1,3 @@
|
||||
meshtasticd (2.7.27.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.27
|
||||
|
||||
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Wed, 24 Jun 2026 11:20:05 +0000
|
||||
|
||||
meshtasticd (2.7.26.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.26
|
||||
|
||||
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Wed, 10 Jun 2026 00:19:23 +0000
|
||||
|
||||
meshtasticd (2.7.25.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.25
|
||||
|
||||
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Sat, 23 May 2026 01:16:20 +0000
|
||||
|
||||
meshtasticd (2.7.24.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.24
|
||||
|
||||
Vendored
+1
-1
@@ -33,5 +33,5 @@ if [[ -n $GPG_KEY_ID ]]; then
|
||||
debuild -S -nc -k"$GPG_KEY_ID"
|
||||
else
|
||||
# Build the source deb without signing (forks)
|
||||
debuild -S -nc -us -uc
|
||||
debuild -S -nc
|
||||
fi
|
||||
|
||||
@@ -7,12 +7,6 @@ __pycache__/
|
||||
dist/
|
||||
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
|
||||
tests/report.html
|
||||
tests/junit.xml
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
#!/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,33 +108,18 @@ def build(
|
||||
env: str,
|
||||
with_manifest: bool = True,
|
||||
userprefs_overrides: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run `pio run -e <env>` and return artifact paths.
|
||||
|
||||
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
|
||||
into userPrefs.jsonc for this build only. File is restored byte-for-byte
|
||||
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]
|
||||
if with_manifest:
|
||||
args.extend(["-t", "mtjson"])
|
||||
extra_env = _build_flags_env(build_flags) if build_flags else None
|
||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||
result = pio.run(
|
||||
args,
|
||||
timeout=pio.TIMEOUT_BUILD,
|
||||
check=False,
|
||||
extra_env=extra_env,
|
||||
)
|
||||
result = pio.run(args, timeout=pio.TIMEOUT_BUILD, check=False)
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"artifacts": [str(p) for p in _artifacts_for(env)],
|
||||
@@ -142,27 +127,9 @@ def build(
|
||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||
"duration_s": round(result.duration_s, 2),
|
||||
"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]:
|
||||
"""Run `pio run -e <env> -t clean`."""
|
||||
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
|
||||
@@ -179,29 +146,20 @@ def flash(
|
||||
port: str,
|
||||
confirm: bool = False,
|
||||
userprefs_overrides: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
||||
|
||||
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
||||
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")
|
||||
_reject_native_env(env, "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:
|
||||
result = pio.run(
|
||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||
timeout=pio.TIMEOUT_UPLOAD,
|
||||
check=False,
|
||||
extra_env=extra_env,
|
||||
)
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
@@ -209,7 +167,6 @@ def flash(
|
||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||
"duration_s": round(result.duration_s, 2),
|
||||
"userprefs": _userprefs_summary(effective),
|
||||
"build_flags": dict(build_flags) if build_flags else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
"""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,7 +92,6 @@ def _run_capturing(
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = None,
|
||||
tee_header: str | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> tuple[int, str, str, float]:
|
||||
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
|
||||
|
||||
@@ -100,9 +99,6 @@ def _run_capturing(
|
||||
`subprocess.TimeoutExpired` on timeout (callers map this to their own
|
||||
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
|
||||
configured (unchanged behavior).
|
||||
|
||||
@@ -114,9 +110,6 @@ def _run_capturing(
|
||||
"""
|
||||
log_path = _flash_log_path()
|
||||
t0 = time.monotonic()
|
||||
env = None
|
||||
if extra_env:
|
||||
env = {**os.environ, **extra_env}
|
||||
|
||||
if log_path is None:
|
||||
# Fast path — unchanged.
|
||||
@@ -126,7 +119,6 @@ def _run_capturing(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
return (
|
||||
proc.returncode,
|
||||
@@ -153,7 +145,6 @@ def _run_capturing(
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1, # line-buffered
|
||||
env=env,
|
||||
)
|
||||
stdout_chunks: list[str] = []
|
||||
stderr_chunks: list[str] = []
|
||||
@@ -241,17 +232,12 @@ def run(
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = TIMEOUT_DEFAULT,
|
||||
check: bool = True,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> PioResult:
|
||||
"""Invoke `pio <args>` and return captured output.
|
||||
|
||||
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
||||
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
|
||||
line-by-line as it arrives (for live flash progress in the TUI).
|
||||
"""
|
||||
@@ -264,7 +250,6 @@ def run(
|
||||
cwd=work_dir,
|
||||
timeout=timeout,
|
||||
tee_header=f"pio {' '.join(args)}",
|
||||
extra_env=extra_env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,309 +0,0 @@
|
||||
"""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}
|
||||
@@ -1,467 +0,0 @@
|
||||
"""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
|
||||
@@ -1,163 +0,0 @@
|
||||
"""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,23 +46,7 @@ class SerialSession:
|
||||
|
||||
|
||||
def _drain(session: SerialSession) -> None:
|
||||
"""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
|
||||
|
||||
"""Reader thread: line-by-line pull stdout into buffer."""
|
||||
assert session.proc.stdout is not None
|
||||
try:
|
||||
for line in session.proc.stdout:
|
||||
@@ -70,16 +54,6 @@ def _drain(session: SerialSession) -> None:
|
||||
with session.lock:
|
||||
session.buffer.append(line_stripped)
|
||||
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
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -6,7 +6,6 @@ etc.). Business logic does not live here.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
@@ -18,34 +17,14 @@ from . import (
|
||||
flash,
|
||||
hw_tools,
|
||||
info,
|
||||
log_query,
|
||||
registry,
|
||||
serial_session,
|
||||
)
|
||||
from . import userprefs as userprefs_mod
|
||||
from .recorder import get_recorder
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
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 ------------------------------------------
|
||||
|
||||
|
||||
@@ -96,7 +75,6 @@ def build(
|
||||
env: str,
|
||||
with_manifest: bool = True,
|
||||
userprefs: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build firmware for one env via `pio run -e <env>`.
|
||||
|
||||
@@ -108,21 +86,8 @@ def build(
|
||||
build via userPrefs.jsonc injection. The file is restored after the build
|
||||
completes. Use `userprefs_manifest` to discover available keys. Use
|
||||
`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,
|
||||
build_flags=build_flags,
|
||||
)
|
||||
return flash.build(env, with_manifest=with_manifest, userprefs_overrides=userprefs)
|
||||
|
||||
|
||||
@app.tool()
|
||||
@@ -140,7 +105,6 @@ def pio_flash(
|
||||
port: str,
|
||||
confirm: bool = False,
|
||||
userprefs: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
|
||||
|
||||
@@ -150,19 +114,8 @@ def pio_flash(
|
||||
|
||||
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
|
||||
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,
|
||||
build_flags=build_flags,
|
||||
)
|
||||
return flash.flash(env, port, confirm=confirm, userprefs_overrides=userprefs)
|
||||
|
||||
|
||||
@app.tool()
|
||||
@@ -781,185 +734,3 @@ def picotool_load(uf2_path: 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."""
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""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
|
||||
@@ -1,548 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
+12
-15
@@ -57,7 +57,6 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
||||
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
|
||||
-DMESHTASTIC_EXCLUDE_POWERMON=1
|
||||
-DMESHTASTIC_EXCLUDE_STATUS=1
|
||||
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
|
||||
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
|
||||
#-D OLED_PL=1
|
||||
@@ -68,7 +67,7 @@ monitor_speed = 115200
|
||||
monitor_filters = direct
|
||||
lib_deps =
|
||||
# renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master
|
||||
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/2e26010040e028baee72e2093402fa7b3c59e430.zip
|
||||
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip
|
||||
# renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master
|
||||
https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip
|
||||
# renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master
|
||||
@@ -121,12 +120,12 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
||||
|
||||
[device-ui_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
|
||||
https://github.com/meshtastic/device-ui/archive/1c45ebc7433acb8ba3fe96a6f7deca9c43fa54cf.zip
|
||||
https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip
|
||||
|
||||
; Common libs for environmental measurements in telemetry module
|
||||
[environmental_base]
|
||||
@@ -138,9 +137,9 @@ lib_deps =
|
||||
# renovate: datasource=github-tags depName=Adafruit GFX packageName=adafruit/Adafruit-GFX-Library
|
||||
https://github.com/adafruit/Adafruit-GFX-Library/archive/refs/tags/1.12.6.zip
|
||||
# renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel
|
||||
https://github.com/adafruit/Adafruit_NeoPixel/archive/1.15.5.zip
|
||||
https://github.com/adafruit/Adafruit_NeoPixel/archive/refs/tags/1.15.4.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SSD1306 packageName=adafruit/Adafruit_SSD1306
|
||||
https://github.com/adafruit/Adafruit_SSD1306/archive/2.5.17.zip
|
||||
https://github.com/adafruit/Adafruit_SSD1306/archive/refs/tags/2.5.16.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit BMP280 packageName=adafruit/Adafruit_BMP280_Library
|
||||
https://github.com/adafruit/Adafruit_BMP280_Library/archive/refs/tags/3.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit BMP085 packageName=adafruit/Adafruit-BMP085-Library
|
||||
@@ -171,8 +170,8 @@ lib_deps =
|
||||
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
||||
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
|
||||
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
|
||||
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
|
||||
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
|
||||
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
||||
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
||||
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
||||
@@ -185,22 +184,16 @@ lib_deps =
|
||||
https://github.com/sparkfun/SparkFun_MAX3010x_Sensor_Library/archive/refs/tags/v1.1.2.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun 9DoF IMU Breakout ICM 20948 packageName=sparkfun/SparkFun_ICM-20948_ArduinoLibrary
|
||||
https://github.com/sparkfun/SparkFun_ICM-20948_ArduinoLibrary/archive/refs/tags/v1.3.2.zip
|
||||
# renovate: datasource=github-tags depName=TDK InvenSense ICM42670P packageName=tdk-invn-oss/motion.arduino.ICM42670P
|
||||
https://github.com/tdk-invn-oss/motion.arduino.ICM42670P/archive/refs/tags/1.0.8.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LTR390 Library packageName=adafruit/Adafruit_LTR390
|
||||
https://github.com/adafruit/Adafruit_LTR390/archive/refs/tags/1.1.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit PCT2075 packageName=adafruit/Adafruit_PCT2075
|
||||
https://github.com/adafruit/Adafruit_PCT2075/archive/refs/tags/1.0.6.zip
|
||||
# renovate: datasource=github-tags depName=DFRobot_BMM150 packageName=dfrobot/DFRobot_BMM150
|
||||
https://github.com/DFRobot/DFRobot_BMM150/archive/refs/tags/V1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun MMC5983MA Magnetometer packageName=sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library/archive/v1.1.5.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit_TSL2561 packageName=adafruit/Adafruit_TSL2561
|
||||
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
|
||||
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
|
||||
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHTC3 packageName=adafruit/Adafruit_SHTC3
|
||||
https://github.com/adafruit/Adafruit_SHTC3/archive/refs/tags/1.0.2.zip
|
||||
|
||||
; Common environmental sensor libraries (not included in native / portduino)
|
||||
[environmental_extra_common]
|
||||
@@ -209,6 +202,8 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
|
||||
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHTC3 packageName=adafruit/Adafruit_SHTC3
|
||||
https://github.com/adafruit/Adafruit_SHTC3/archive/refs/tags/1.0.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
|
||||
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT31 packageName=adafruit/Adafruit_SHT31
|
||||
@@ -230,7 +225,9 @@ lib_deps =
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/1.1.1.zip
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
|
||||
+1
-1
Submodule protobufs updated: 6b1ded4396...149586802f
@@ -151,7 +151,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess
|
||||
if (!this->_enabled)
|
||||
return false;
|
||||
|
||||
if ((this->_server == NULL && this->_ip == IPAddress(0, 0, 0, 0)) || this->_port == 0)
|
||||
if ((this->_server == NULL && this->_ip == INADDR_NONE) || this->_port == 0)
|
||||
return false;
|
||||
|
||||
// Check priority against priMask values.
|
||||
|
||||
@@ -13,11 +13,6 @@ extern MemGet memGet;
|
||||
#define LED_STATE_ON 1
|
||||
#endif
|
||||
|
||||
// WIFI LED
|
||||
#ifndef WIFI_STATE_ON
|
||||
#define WIFI_STATE_ON 1
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DEBUG
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -152,9 +147,7 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
|
||||
// Default Bluetooth PIN
|
||||
#define defaultBLEPin 123456
|
||||
|
||||
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||
#include <ESP32_CH390.h>
|
||||
#elif HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#include <RAK13800_W5100S.h>
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
||||
bool usePreset)
|
||||
{
|
||||
|
||||
// If use_preset is false, always return "Custom"
|
||||
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
|
||||
// rely on this being a stable literal for channel-name hashing and default-channel detection.
|
||||
if (!usePreset) {
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
+81
-185
@@ -79,107 +79,84 @@ bool copyFile(const char *from, const char *to)
|
||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// take SPI Lock
|
||||
spiLock->lock();
|
||||
// rename was fixed for ESP32 IDF LittleFS in April
|
||||
bool result = FSCom.rename(pathFrom, pathTo);
|
||||
spiLock->unlock();
|
||||
return result;
|
||||
#else
|
||||
// copyFile does its own locking.
|
||||
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Platform-agnostic filesystem format / wipe.
|
||||
*
|
||||
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
|
||||
* native FSCom.format() which erases and reinitialises the LittleFS
|
||||
* partition.
|
||||
*
|
||||
* On Portduino the fs::FS backend has no format() method. We instead
|
||||
* delete /prefs (the only meshtastic data directory written at runtime)
|
||||
* and return. rmDir("/prefs") is already called unconditionally by
|
||||
* factoryReset() so this is a proven primitive on Portduino.
|
||||
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
|
||||
*
|
||||
* @return true on success, false on failure or if no filesystem is configured.
|
||||
*/
|
||||
bool fsFormat()
|
||||
{
|
||||
#ifdef FSCom
|
||||
namespace
|
||||
{
|
||||
bool pathEndsWithDot(const char *path)
|
||||
{
|
||||
if (!path)
|
||||
return false;
|
||||
|
||||
size_t length = strlen(path);
|
||||
return length > 0 && path[length - 1] == '.';
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
rmDir("/prefs");
|
||||
return FSBegin();
|
||||
#else
|
||||
return FSCom.format();
|
||||
#endif
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool copyFilePath(char *dest, size_t destSize, const char *path, bool *wasLimited)
|
||||
/**
|
||||
* @brief Get the list of files in a directory.
|
||||
*
|
||||
* This function returns a list of files in a directory. The list includes the full path of each file.
|
||||
* We can't use SPILOCK here because of recursion. Callers of this function should use SPILOCK.
|
||||
*
|
||||
* @param dirname The name of the directory.
|
||||
* @param levels The number of levels of subdirectories to list.
|
||||
* @return A vector of strings containing the full path of each file in the directory.
|
||||
*/
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
{
|
||||
if (!path || destSize == 0) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlcpy(dest, path, destSize) >= destSize) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vector<meshtastic_FileInfo> &filenames,
|
||||
bool *wasLimited)
|
||||
{
|
||||
if (!dirname)
|
||||
return;
|
||||
|
||||
std::vector<meshtastic_FileInfo> filenames = {};
|
||||
#ifdef FSCom
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root)
|
||||
return;
|
||||
if (!root.isDirectory()) {
|
||||
root.close();
|
||||
return;
|
||||
}
|
||||
return filenames;
|
||||
if (!root.isDirectory())
|
||||
return filenames;
|
||||
|
||||
File file = root.openNextFile();
|
||||
// file.name()[0] check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
while (file && file.name()[0]) {
|
||||
if (filenames.size() >= maxCount) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
file.close();
|
||||
break;
|
||||
}
|
||||
const char *fileName = file.name();
|
||||
if (file.isDirectory() && !pathEndsWithDot(fileName)) {
|
||||
char pathBuffer[sizeof(((meshtastic_FileInfo *)nullptr)->file_name)] = {};
|
||||
while (file) {
|
||||
#ifdef ARCH_ESP32
|
||||
const char *subDirPath = file.path();
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *subDirPath = fileName;
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
bool hasSubDirPath = copyFilePath(pathBuffer, sizeof(pathBuffer), subDirPath, wasLimited);
|
||||
file.close();
|
||||
|
||||
if (levels && hasSubDirPath) {
|
||||
collectFiles(pathBuffer, levels - 1, maxCount, filenames, wasLimited);
|
||||
} else if (wasLimited) {
|
||||
*wasLimited = true;
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
#ifdef ARCH_ESP32
|
||||
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.path(), wasLimited);
|
||||
#else
|
||||
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.name(), wasLimited);
|
||||
#endif
|
||||
if (hasFilePath && !pathEndsWithDot(fileInfo.file_name)) {
|
||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
file.close();
|
||||
@@ -187,41 +164,6 @@ void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vec
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
}
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
// Callers must hold the SPI lock; recursion prevents taking it here.
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount, bool *wasLimited)
|
||||
{
|
||||
std::vector<meshtastic_FileInfo> filenames = {};
|
||||
if (wasLimited)
|
||||
*wasLimited = false;
|
||||
#ifdef FSCom
|
||||
#if defined(__cpp_exceptions) || defined(__EXCEPTIONS)
|
||||
size_t reservedCount = maxCount;
|
||||
while (reservedCount > 0) {
|
||||
try {
|
||||
filenames.reserve(reservedCount);
|
||||
break;
|
||||
} catch (const std::bad_alloc &) {
|
||||
reservedCount /= 2;
|
||||
} catch (const std::length_error &) {
|
||||
reservedCount /= 2;
|
||||
}
|
||||
}
|
||||
if (reservedCount == 0) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return filenames;
|
||||
}
|
||||
if (reservedCount < maxCount) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
maxCount = reservedCount;
|
||||
}
|
||||
#endif
|
||||
collectFiles(dirname, levels, maxCount, filenames, wasLimited);
|
||||
#endif
|
||||
return filenames;
|
||||
}
|
||||
@@ -237,98 +179,59 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, s
|
||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
char buffer[255];
|
||||
#endif
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root) {
|
||||
if (!root || !root.isDirectory())
|
||||
return;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (
|
||||
file &&
|
||||
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
listDir(file.path(), levels - 1, del);
|
||||
listDir(filepath, levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Remove %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(file.name(), levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (directory)", file.name());
|
||||
listDir(file.name(), levels - 1, del);
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Delete %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.path());
|
||||
strncpy(buffer, root.path(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.name());
|
||||
strncpy(buffer, root.name(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
const char *rootpath = root.path();
|
||||
#else
|
||||
root.close();
|
||||
const char *rootpath = root.name();
|
||||
#endif
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", rootpath);
|
||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -342,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
void rmDir(const char *dirname)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(dirname, 10, true);
|
||||
#elif defined(ARCH_NRF52)
|
||||
// nRF52 implementation of LittleFS has a recursive delete function
|
||||
FSCom.rmdir_r(dirname);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -409,4 +305,4 @@ void setupSDCard()
|
||||
LOG_DEBUG("Total space: %lu MB", (uint32_t)(SD.totalBytes() / (1024 * 1024)));
|
||||
LOG_DEBUG("Used space: %lu MB", (uint32_t)(SD.usedBytes() / (1024 * 1024)));
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -52,7 +52,8 @@ void fsInit();
|
||||
void fsListFiles();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||
void rmDir(const char *dirname);
|
||||
void setupSDCard();
|
||||
void setupSDCard();
|
||||
+3
-6
@@ -53,8 +53,7 @@ class GPSStatus : public Status
|
||||
int32_t getLatitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.latitude_i;
|
||||
return localPosition.latitude_i;
|
||||
} else {
|
||||
return p.latitude_i;
|
||||
}
|
||||
@@ -63,8 +62,7 @@ class GPSStatus : public Status
|
||||
int32_t getLongitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.longitude_i;
|
||||
return localPosition.longitude_i;
|
||||
} else {
|
||||
return p.longitude_i;
|
||||
}
|
||||
@@ -73,8 +71,7 @@ class GPSStatus : public Status
|
||||
int32_t getAltitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.altitude;
|
||||
return localPosition.altitude;
|
||||
} else {
|
||||
return p.altitude;
|
||||
}
|
||||
|
||||
+26
-32
@@ -6,7 +6,6 @@
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/draw/MessageRenderer.h"
|
||||
#include <cstring> // memcpy
|
||||
|
||||
#ifndef MESSAGE_TEXT_POOL_SIZE
|
||||
@@ -181,13 +180,8 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
|
||||
|
||||
bool isDM = (sm.dest != 0 && sm.dest != NODENUM_BROADCAST);
|
||||
|
||||
if (packet.from == 0) {
|
||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
||||
sm.ackStatus = AckStatus::NONE;
|
||||
} else {
|
||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
||||
sm.ackStatus = AckStatus::ACKED;
|
||||
}
|
||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
||||
sm.ackStatus = (packet.from == 0) ? AckStatus::NONE : AckStatus::ACKED;
|
||||
|
||||
addLiveMessage(sm);
|
||||
|
||||
@@ -360,7 +354,6 @@ void MessageStore::clearAllMessages()
|
||||
resetMessagePool();
|
||||
|
||||
#ifdef FSCom
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
SafeFile f(filename.c_str(), false);
|
||||
uint8_t count = 0;
|
||||
f.write(&count, 1); // write "0 messages"
|
||||
@@ -373,26 +366,25 @@ void MessageStore::clearAllMessages()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Internal helper: erase first or last message matching a predicate
|
||||
template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deque, Predicate pred, bool fromBack = false)
|
||||
// Internal helpers for targeted erasure.
|
||||
template <typename Predicate> static bool eraseFirstMatch(std::deque<StoredMessage> &deque, Predicate pred)
|
||||
{
|
||||
if (fromBack) {
|
||||
// Iterate from the back and erase all matches from the end
|
||||
for (auto it = deque.rbegin(); it != deque.rend();) {
|
||||
if (pred(*it)) {
|
||||
it = std::deque<StoredMessage>::reverse_iterator(deque.erase(std::next(it).base()));
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
for (auto it = deque.begin(); it != deque.end(); ++it) {
|
||||
if (pred(*it)) {
|
||||
deque.erase(it);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Manual forward search to erase all matches
|
||||
for (auto it = deque.begin(); it != deque.end();) {
|
||||
if (pred(*it)) {
|
||||
it = deque.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
template <typename Predicate> static void eraseAllMatches(std::deque<StoredMessage> &deque, Predicate pred)
|
||||
{
|
||||
for (auto it = deque.begin(); it != deque.end();) {
|
||||
if (pred(*it)) {
|
||||
it = deque.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +392,9 @@ template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deq
|
||||
// Delete oldest message (RAM + persisted queue)
|
||||
void MessageStore::deleteOldestMessage()
|
||||
{
|
||||
eraseIf(liveMessages, [](StoredMessage &) { return true; });
|
||||
if (!liveMessages.empty()) {
|
||||
liveMessages.pop_front();
|
||||
}
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
@@ -408,14 +402,14 @@ void MessageStore::deleteOldestMessage()
|
||||
void MessageStore::deleteOldestMessageInChannel(uint8_t channel)
|
||||
{
|
||||
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
||||
eraseIf(liveMessages, pred);
|
||||
eraseFirstMatch(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
void MessageStore::deleteAllMessagesInChannel(uint8_t channel)
|
||||
{
|
||||
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
||||
eraseIf(liveMessages, pred, false /* delete ALL, not just first */);
|
||||
eraseAllMatches(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
@@ -428,7 +422,7 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer)
|
||||
uint32_t other = (m.sender == local) ? m.dest : m.sender;
|
||||
return other == peer;
|
||||
};
|
||||
eraseIf(liveMessages, pred, false);
|
||||
eraseAllMatches(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
@@ -441,7 +435,7 @@ void MessageStore::deleteOldestMessageWithPeer(uint32_t peer)
|
||||
uint32_t other = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
|
||||
return other == peer;
|
||||
};
|
||||
eraseIf(liveMessages, pred);
|
||||
eraseFirstMatch(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +124,6 @@ class MessageStore
|
||||
// Allocate text into pool (used by sender-side code)
|
||||
static uint16_t storeText(const char *src, size_t len);
|
||||
|
||||
// Used when loading from flash to rebuild the text pool
|
||||
static uint16_t rebuildTextFromFlash(const char *src, size_t len);
|
||||
|
||||
private:
|
||||
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
|
||||
std::string filename; // Flash filename for persistence
|
||||
|
||||
+83
-83
@@ -23,7 +23,6 @@
|
||||
#include "main.h"
|
||||
#include "meshUtils.h"
|
||||
#include "power/PowerHAL.h"
|
||||
#include "power/SGM41562.h"
|
||||
#include "sleep.h"
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
@@ -41,6 +40,22 @@
|
||||
#include "concurrency/LockGuard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
|
||||
#include "stm32yyxx_ll_adc.h"
|
||||
|
||||
/* Analog read resolution */
|
||||
#if defined(LL_ADC_RESOLUTION_12B)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_RESOLUTION_12B
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#elif defined(LL_ADC_DS_DATA_WIDTH_12_BIT)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_DS_DATA_WIDTH_12_BIT
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#else
|
||||
#error "ADC resolution could not be defined!"
|
||||
#endif
|
||||
#define ADC_RANGE (1 << BATTERY_SENSE_RESOLUTION_BITS)
|
||||
#endif
|
||||
|
||||
#if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
#include "target_specific.h"
|
||||
@@ -79,16 +94,31 @@ static const adc_atten_t atten = ADC_ATTENUATION;
|
||||
#endif
|
||||
#endif // BATTERY_PIN && ARCH_ESP32
|
||||
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#ifndef EXT_PWR_DETECT_MODE
|
||||
#define EXT_PWR_DETECT_MODE INPUT
|
||||
// If using internal pull resistors, we can infer EXT_PWR_DETECT_VALUE
|
||||
#elif EXT_PWR_DETECT_MODE == INPUT_PULLUP
|
||||
#define EXT_PWR_DETECT_VALUE LOW
|
||||
#elif EXT_PWR_DETECT_MODE == INPUT_PULLDOWN
|
||||
#define EXT_PWR_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#ifndef EXT_PWR_DETECT_VALUE
|
||||
#define EXT_PWR_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
#ifndef EXT_CHRG_DETECT_MODE
|
||||
static const uint8_t ext_chrg_detect_mode = INPUT;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_mode = EXT_CHRG_DETECT_MODE;
|
||||
#define EXT_CHRG_DETECT_MODE INPUT
|
||||
// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE
|
||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP
|
||||
#define EXT_CHRG_DETECT_VALUE LOW
|
||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN
|
||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#ifndef EXT_CHRG_DETECT_VALUE
|
||||
static const uint8_t ext_chrg_detect_value = HIGH;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_value = EXT_CHRG_DETECT_VALUE;
|
||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -329,11 +359,17 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_ESP32 // ADC block for espressif platforms
|
||||
#ifdef ARCH_STM32WL
|
||||
// STM32 ADC with VREFINT runtime calibration
|
||||
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
|
||||
raw = analogRead(BATTERY_PIN);
|
||||
scaled = __LL_ADC_CALC_DATA_TO_VOLTAGE(Vref, raw, LL_ADC_RESOLUTION);
|
||||
scaled *= operativeAdcMultiplier;
|
||||
#elif defined(ARCH_ESP32) // ADC block for espressif platforms
|
||||
raw = espAdcRead();
|
||||
scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs);
|
||||
scaled *= operativeAdcMultiplier;
|
||||
#else // block for all other platforms
|
||||
#else // block for all other platforms
|
||||
#ifdef ARCH_NRF52
|
||||
concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock);
|
||||
#endif
|
||||
@@ -448,32 +484,14 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; }
|
||||
#endif
|
||||
|
||||
/// If we see a battery voltage higher than physics allows - assume charger is
|
||||
/// pumping in power On some boards we don't have the power management chip
|
||||
/// (like AXPxxxx) so we use EXT_PWR_DETECT GPIO pin to detect external power
|
||||
/// source
|
||||
// Detect if an external power source is connected if we don’t have a PMIC;
|
||||
// Firstly prefer EXT_PWR_DETECT GPIO if available,
|
||||
// secondly try an nRF52-specific routine on some variants,
|
||||
// lastly provide a fallback to indicate external power when fully charged.
|
||||
virtual bool isVbusIn() override
|
||||
{
|
||||
#ifdef HAS_SGM41562
|
||||
if (sgm41562 && sgm41562->refresh())
|
||||
return sgm41562->isInputPowerGood();
|
||||
#endif
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||
// if external powered that pin will be pulled down
|
||||
if (digitalRead(EXT_PWR_DETECT) == LOW) {
|
||||
return true;
|
||||
}
|
||||
// if it's not LOW - check the battery
|
||||
#else
|
||||
// if external powered that pin will be pulled up
|
||||
if (digitalRead(EXT_PWR_DETECT) == HIGH) {
|
||||
return true;
|
||||
}
|
||||
// if it's not HIGH - check the battery
|
||||
#endif
|
||||
// If we have an EXT_PWR_DETECT pin and it indicates no external power, believe it.
|
||||
return false;
|
||||
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
|
||||
|
||||
// technically speaking this should work for all(?) NRF52 boards
|
||||
// but needs testing across multiple devices. NRF52 USB would not even work if
|
||||
@@ -488,19 +506,15 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
/// we can't be smart enough to say 'full'?
|
||||
virtual bool isCharging() override
|
||||
{
|
||||
#ifdef HAS_SGM41562
|
||||
if (sgm41562 && sgm41562->refresh())
|
||||
return sgm41562->isCharging();
|
||||
#endif
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT) && !defined(HAS_PMU)
|
||||
if (hasRAK()) {
|
||||
return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse;
|
||||
}
|
||||
#endif
|
||||
#if defined(ELECROW_ThinkNode_M6)
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value || isVbusIn();
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn();
|
||||
#elif EXT_CHRG_DETECT
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
#elif defined(BATTERY_CHARGING_INV)
|
||||
return !digitalRead(BATTERY_CHARGING_INV);
|
||||
#else
|
||||
@@ -539,6 +553,11 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
bool initial_read_done = false;
|
||||
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
||||
uint32_t last_read_time_ms = 0;
|
||||
#ifdef ARCH_STM32WL
|
||||
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
|
||||
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
|
||||
uint32_t Vref = 3300;
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT)
|
||||
|
||||
@@ -628,14 +647,10 @@ Power::Power() : OSThread("Power")
|
||||
bool Power::analogInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||
pinMode(EXT_PWR_DETECT, INPUT_PULLUP);
|
||||
#else
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
#endif
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
||||
#endif
|
||||
|
||||
#ifdef BATTERY_PIN
|
||||
@@ -648,7 +663,9 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_ESP32 // ESP32 needs special analog stuff
|
||||
#ifdef ARCH_STM32WL
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
|
||||
|
||||
#ifndef ADC_WIDTH // max resolution by default
|
||||
static const adc_bits_width_t width = ADC_WIDTH_BIT_12;
|
||||
@@ -658,7 +675,7 @@ bool Power::analogInit()
|
||||
#ifndef BAT_MEASURE_ADC_UNIT // ADC1
|
||||
adc1_config_width(width);
|
||||
adc1_config_channel_atten(adc_channel, atten);
|
||||
#else // ADC2
|
||||
#else // ADC2
|
||||
adc2_config_channel_atten(adc_channel, atten);
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32S3
|
||||
// ADC2 wifi bug workaround
|
||||
@@ -688,7 +705,7 @@ bool Power::analogInit()
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#ifndef ARCH_ESP32
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
@@ -706,12 +723,6 @@ bool Power::analogInit()
|
||||
*/
|
||||
bool Power::setup()
|
||||
{
|
||||
#ifdef HAS_SGM41562
|
||||
// Initialize the charger early so AnalogBatteryLevel can read charging
|
||||
// state from it. The charger does not provide battery voltage / percent —
|
||||
// those still come from the platform ADC via analogInit() below.
|
||||
initSGM41562(SGM41562_WIRE);
|
||||
#endif
|
||||
bool found = false;
|
||||
if (axpChipInit()) {
|
||||
found = true;
|
||||
@@ -893,16 +904,7 @@ void Power::readPowerStatus()
|
||||
|
||||
// Notify any status instances that are observing us
|
||||
const PowerStatus powerStatus2 = PowerStatus(hasBattery, usbPowered, isChargingNow, batteryVoltageMv, batteryChargePercent);
|
||||
|
||||
// Log battery-presence transitions once; skip OptUnknown so we don't lie before the first probe.
|
||||
static OptionalBool prevHasBattery = OptUnknown;
|
||||
if (hasBattery != OptUnknown && hasBattery != prevHasBattery) {
|
||||
LOG_INFO("Power: battery hardware %s", hasBattery == OptTrue ? "detected" : "absent (USB-only)");
|
||||
prevHasBattery = hasBattery;
|
||||
}
|
||||
|
||||
// Periodic telemetry only emits when a battery is actually present (otherwise values are constant -1/0).
|
||||
if (hasBattery == OptTrue && !Throttle::isWithinTimespanMs(lastLogTime, 50 * 1000)) {
|
||||
if (millis() > lastLogTime + 50 * 1000) {
|
||||
LOG_DEBUG("Battery: usbPower=%d, isCharging=%d, batMv=%d, batPct=%d", powerStatus2.getHasUSB(),
|
||||
powerStatus2.getIsCharging(), powerStatus2.getBatteryVoltageMv(), powerStatus2.getBatteryChargePercent());
|
||||
lastLogTime = millis();
|
||||
@@ -1000,6 +1002,14 @@ int32_t Power::runOnce()
|
||||
powerFSM.trigger(EVENT_POWER_CONNECTED);
|
||||
}
|
||||
|
||||
#ifdef PMU_POWER_BUTTON_IS_CANCEL
|
||||
// cancel action also turns the screen on and off.
|
||||
if (PMU->isPekeyShortPressIrq()) {
|
||||
LOG_INFO("Input: Corona Button Click");
|
||||
InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0};
|
||||
inputBroker->injectInputEvent(&event);
|
||||
}
|
||||
#endif
|
||||
/*
|
||||
Other things we could check if we cared...
|
||||
|
||||
@@ -1016,13 +1026,6 @@ int32_t Power::runOnce()
|
||||
LOG_DEBUG("Battery removed");
|
||||
}
|
||||
*/
|
||||
#ifndef T_WATCH_S3 // FIXME - why is this triggering on the T-Watch S3?
|
||||
if (PMU->isPekeyLongPressIrq()) {
|
||||
LOG_DEBUG("PEK long button press");
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
PMU->clearIrqStatus();
|
||||
}
|
||||
@@ -1091,7 +1094,7 @@ void Power::attachPowerInterrupts()
|
||||
if (PMU) {
|
||||
attachInterrupt(
|
||||
PMU_IRQ,
|
||||
[] {
|
||||
[]() {
|
||||
pmu_irq = true;
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
@@ -1394,19 +1397,16 @@ bool Power::axpChipInit()
|
||||
uint64_t pmuIrqMask = 0;
|
||||
|
||||
if (PMU->getChipModel() == XPOWERS_AXP192) {
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_BAT_INSERT_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_VBUS_REMOVE_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
} else if (PMU->getChipModel() == XPOWERS_AXP2101) {
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_BAT_INSERT_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_VBUS_REMOVE_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
}
|
||||
|
||||
pinMode(PMU_IRQ, INPUT);
|
||||
|
||||
// we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ
|
||||
// because it occurs repeatedly while there is no battery also it could cause
|
||||
// inadvertent waking from light sleep just because the battery filled we
|
||||
// don't look for AXPXXX_BATT_REMOVED_IRQ because it occurs repeatedly while
|
||||
// no battery installed we don't look at AXPXXX_VBUS_REMOVED_IRQ because we
|
||||
// don't have anything hooked to vbus
|
||||
// We wake on IRQ, so only enable the IRQs that we care about.
|
||||
// we want USB plug and unplug to update the screen and LED status,
|
||||
// and short press on the power button to trigger the "cancel" action in the UI (which also turns the screen on and off).
|
||||
PMU->enableIRQ(pmuIrqMask);
|
||||
|
||||
PMU->clearIrqStatus();
|
||||
@@ -1872,7 +1872,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
||||
{
|
||||
#if defined(EXT_CHRG_DETECT)
|
||||
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
|
||||
#endif
|
||||
return false;
|
||||
@@ -1881,7 +1881,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
||||
virtual bool isCharging() override
|
||||
{
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
|
||||
#endif
|
||||
// by default, we check the battery voltage only
|
||||
@@ -1903,10 +1903,10 @@ SerialBatteryLevel serialBatteryLevel;
|
||||
bool Power::serialBatteryInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
||||
#endif
|
||||
|
||||
bool result = serialBatteryLevel.runOnce();
|
||||
|
||||
+48
-11
@@ -58,6 +58,35 @@ static bool isPowered()
|
||||
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
|
||||
}
|
||||
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
static void t5BacklightOffForSleep()
|
||||
{
|
||||
t5BacklightSetForcedBySleep(true);
|
||||
}
|
||||
|
||||
static void t5BacklightWakeFromSleep()
|
||||
{
|
||||
t5BacklightSetForcedBySleep(false);
|
||||
}
|
||||
|
||||
static void t5BacklightOffForTimeout()
|
||||
{
|
||||
t5BacklightSetForcedByTimeout(true);
|
||||
t5TouchSetForcedByTimeout(true);
|
||||
}
|
||||
|
||||
static void t5BacklightOnFromUserInput()
|
||||
{
|
||||
t5BacklightHandleUserInput();
|
||||
t5TouchHandleUserInput();
|
||||
}
|
||||
#else
|
||||
static void t5BacklightOffForSleep() {}
|
||||
static void t5BacklightWakeFromSleep() {}
|
||||
static void t5BacklightOffForTimeout() {}
|
||||
static void t5BacklightOnFromUserInput() {}
|
||||
#endif
|
||||
|
||||
static void sdsEnter()
|
||||
{
|
||||
LOG_POWERFSM("State: SDS");
|
||||
@@ -87,6 +116,7 @@ static void lsEnter()
|
||||
LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs);
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
t5BacklightOffForSleep();
|
||||
secsSlept = 0; // How long have we been sleeping this time
|
||||
|
||||
// LOG_INFO("lsEnter end");
|
||||
@@ -159,6 +189,8 @@ static void lsIdle()
|
||||
static void lsExit()
|
||||
{
|
||||
LOG_POWERFSM("State: lsExit");
|
||||
// Lift the light-sleep force-off gate when leaving LS.
|
||||
t5BacklightWakeFromSleep();
|
||||
}
|
||||
|
||||
static void nbEnter()
|
||||
@@ -180,6 +212,8 @@ static void darkEnter()
|
||||
setBluetoothEnable(true);
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
// Screen timeout enters DARK; ensure backlight also turns off.
|
||||
t5BacklightOffForTimeout();
|
||||
}
|
||||
|
||||
static void serialEnter()
|
||||
@@ -289,12 +323,13 @@ void PowerFSM_setup()
|
||||
powerFSM.add_transition(&stateNB, &stateNB, EVENT_PACKET_FOR_PHONE, NULL, "Received packet, resetting win wake");
|
||||
|
||||
// Handle press events - note: we ignore button presses when in API mode
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL,
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput,
|
||||
"Press"); // reenter On to restart our timers
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, t5BacklightOnFromUserInput,
|
||||
"Press"); // Allow button to work while in serial API
|
||||
|
||||
// Handle critically low power battery by forcing deep sleep
|
||||
@@ -314,11 +349,13 @@ void PowerFSM_setup()
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
|
||||
|
||||
// Inputbroker
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput,
|
||||
"Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, t5BacklightOnFromUserInput,
|
||||
"Input Device"); // restarts the sleep timer
|
||||
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
|
||||
@@ -137,7 +137,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| %02d:%02d:%02d %u ", hour, min, sec, millis() / 1000);
|
||||
::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, millis() % 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
@@ -151,7 +151,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| ??:??:?? %u ", millis() / 1000);
|
||||
::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
|
||||
@@ -7,10 +7,6 @@ static File openFile(const char *filename, bool fullAtomic)
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
||||
#ifdef ARCH_NRF52
|
||||
FSCom.remove(filename);
|
||||
return FSCom.open(filename, FILE_O_WRITE);
|
||||
#endif
|
||||
if (!fullAtomic) {
|
||||
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
||||
}
|
||||
@@ -67,9 +63,6 @@ bool SafeFile::close()
|
||||
f.close();
|
||||
spiLock->unlock();
|
||||
|
||||
#ifdef ARCH_NRF52
|
||||
return true;
|
||||
#endif
|
||||
if (!testReadback())
|
||||
return false;
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ SerialConsole *console;
|
||||
|
||||
void consoleInit()
|
||||
{
|
||||
if (console) {
|
||||
return;
|
||||
}
|
||||
auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread
|
||||
|
||||
#if defined(SERIAL_HAS_ON_RECEIVE)
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel.
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
|
||||
RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel.
|
||||
*/
|
||||
|
||||
+10
-11
@@ -31,11 +31,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#endif
|
||||
#if __has_include("SensorRtcHelper.hpp")
|
||||
#include "SensorRtcHelper.hpp"
|
||||
// SensorLib defines isBitSet as a macro; undefine it here to avoid conflicts
|
||||
// with the SparkFun MMC5983MA library, which has a class method of the same name.
|
||||
#ifdef isBitSet
|
||||
#undef isBitSet
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* Offer chance for variant-specific defines */
|
||||
@@ -83,6 +78,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Pre-hop drop handling (compile-time flag).
|
||||
#ifndef MESHTASTIC_PREHOP_DROP
|
||||
#define MESHTASTIC_PREHOP_DROP 1
|
||||
#endif
|
||||
|
||||
/// Convert a preprocessor name into a quoted string
|
||||
#define xstr(s) ystr(s)
|
||||
#define ystr(s) #s
|
||||
@@ -234,7 +234,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define BME_ADDR 0x76
|
||||
#define BME_ADDR_ALTERNATE 0x77
|
||||
#define MCP9808_ADDR 0x18
|
||||
#define INA_ADDR 0x40
|
||||
#define INA_ADDR 0x40 // same as SHT2X
|
||||
#define INA_ADDR_ALTERNATE 0x41
|
||||
#define INA_ADDR_WAVESHARE_UPS 0x43
|
||||
#define INA3221_ADDR 0x42
|
||||
@@ -243,13 +243,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define QMI8658_ADDR 0x6B
|
||||
#define QMC5883L_ADDR 0x0D
|
||||
#define HMC5883L_ADDR 0x1E
|
||||
#define MMC5983MA_ADDR 0x30
|
||||
#define SHTC3_ADDR 0x70
|
||||
#define LPS22HB_ADDR 0x5C
|
||||
#define LPS22HB_ADDR_ALT 0x5D
|
||||
#define SFA30_ADDR 0x5D
|
||||
#define SHT31_4x_ADDR 0x44
|
||||
#define SHT31_4x_ADDR_ALT 0x45
|
||||
#define SHTXX_ADDR 0x44
|
||||
#define SHTXX_ADDR_ALT 0x45
|
||||
#define PMSA003I_ADDR 0x12
|
||||
#define QMA6100P_ADDR 0x12
|
||||
#define AHT10_ADDR 0x38
|
||||
@@ -293,8 +292,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define DA217_ADDR 0x26
|
||||
#define BMI270_ADDR 0x68
|
||||
#define BMI270_ADDR_ALT 0x69
|
||||
#define ICM42607P_ADDR 0x68
|
||||
#define ICM42607P_ADDR_ALT 0x69
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LED
|
||||
@@ -505,6 +502,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_EXCLUDE_PKI 1
|
||||
#define MESHTASTIC_EXCLUDE_POWER_FSM 1
|
||||
#define MESHTASTIC_EXCLUDE_TZ 1
|
||||
#define MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH 1
|
||||
#endif
|
||||
|
||||
// Turn off all optional modules
|
||||
@@ -521,6 +519,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
|
||||
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
|
||||
#define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1
|
||||
#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1
|
||||
#define MESHTASTIC_EXCLUDE_ATAK 1
|
||||
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
|
||||
#define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1
|
||||
|
||||
@@ -11,7 +11,8 @@ enum LoRaRadioType {
|
||||
SX1280_RADIO,
|
||||
LR1110_RADIO,
|
||||
LR1120_RADIO,
|
||||
LR1121_RADIO
|
||||
LR1121_RADIO,
|
||||
LR2021_RADIO
|
||||
};
|
||||
|
||||
extern LoRaRadioType radioType;
|
||||
@@ -37,15 +37,8 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
|
||||
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
|
||||
return firstOfOrNONE(11, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MMC5983MA};
|
||||
return firstOfOrNONE(1, types);
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX, ICM20948, QMA6100P, BMM150, BMI270};
|
||||
return firstOfOrNONE(10, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAQI() const
|
||||
|
||||
@@ -41,7 +41,6 @@ class ScanI2C
|
||||
QMI8658,
|
||||
QMC5883L,
|
||||
HMC5883L,
|
||||
MMC5983MA,
|
||||
PMSA003I,
|
||||
QMA6100P,
|
||||
MPU6050,
|
||||
@@ -66,7 +65,6 @@ class ScanI2C
|
||||
FT6336U,
|
||||
STK8BAXX,
|
||||
ICM20948,
|
||||
ICM42607P,
|
||||
SCD4X,
|
||||
MAX30102,
|
||||
TPS65233,
|
||||
@@ -151,8 +149,6 @@ class ScanI2C
|
||||
|
||||
FoundDevice firstAccelerometer() const;
|
||||
|
||||
FoundDevice firstMagnetometer() const;
|
||||
|
||||
FoundDevice firstAQI() const;
|
||||
|
||||
FoundDevice firstRGBLED() const;
|
||||
|
||||
@@ -85,9 +85,8 @@ ScanI2C::DeviceType ScanI2CTwoWire::probeOLED(ScanI2C::DeviceAddress addr) const
|
||||
|
||||
return o_probe;
|
||||
}
|
||||
|
||||
uint16_t ScanI2CTwoWire::getRegisterValue(const ScanI2CTwoWire::RegisterLocation ®isterLocation,
|
||||
ScanI2CTwoWire::ResponseWidth responseWidth, bool zeropad) const
|
||||
ScanI2CTwoWire::ResponseWidth responseWidth, bool zeropad = false) const
|
||||
{
|
||||
uint16_t value = 0x00;
|
||||
TwoWire *i2cBus = fetchI2CBus(registerLocation.i2cAddress);
|
||||
@@ -137,7 +136,9 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
|
||||
return match;
|
||||
}
|
||||
|
||||
/// for SEN5X detection
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
// FIXME Move to a separate file for detection of sensors that require more complex interactions?
|
||||
// For SEN5X detection
|
||||
// Note, this code needs to be called before setting the I2C bus speed
|
||||
// for the screen at high speed. The speed needs to be at 100kHz, otherwise
|
||||
// detection will not work
|
||||
@@ -175,24 +176,11 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address)
|
||||
|
||||
return String(productName);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
static uint8_t crcSHT2X(const uint8_t *data, uint8_t len)
|
||||
{
|
||||
uint8_t crc = 0;
|
||||
for (uint8_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; bit++) {
|
||||
crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : crc << 1;
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
{
|
||||
uint8_t serialA[8] = {0};
|
||||
uint8_t serialB[6] = {0};
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFA);
|
||||
@@ -201,13 +189,12 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)sizeof(serialA)) != sizeof(serialA))
|
||||
if (i2cBus->requestFrom(address, (uint8_t)8) != 8)
|
||||
return false;
|
||||
|
||||
for (uint8_t i = 0; i < sizeof(serialA); i++) {
|
||||
if (!i2cBus->available())
|
||||
return false;
|
||||
serialA[i] = i2cBus->read();
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 8) {
|
||||
i2cBus->read();
|
||||
}
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
@@ -217,18 +204,16 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)sizeof(serialB)) != sizeof(serialB))
|
||||
if (i2cBus->requestFrom(address, (uint8_t)6) != 6)
|
||||
return false;
|
||||
|
||||
for (uint8_t i = 0; i < sizeof(serialB); i++) {
|
||||
if (!i2cBus->available())
|
||||
return false;
|
||||
serialB[i] = i2cBus->read();
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 6) {
|
||||
i2cBus->read();
|
||||
}
|
||||
|
||||
return crcSHT2X(&serialA[0], 1) == serialA[1] && crcSHT2X(&serialA[2], 1) == serialA[3] &&
|
||||
crcSHT2X(&serialA[4], 1) == serialA[5] && crcSHT2X(&serialA[6], 1) == serialA[7] &&
|
||||
crcSHT2X(&serialB[0], 2) == serialB[2] && crcSHT2X(&serialB[3], 2) == serialB[5];
|
||||
// Assume we detect the SHT21 if something came back from the request
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -358,6 +343,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address);
|
||||
#ifdef HAS_NCP5623
|
||||
SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address);
|
||||
#endif
|
||||
#ifdef HAS_LP5562
|
||||
SCAN_SIMPLE_CASE(LP5562_ADDR, LP5562, "LP5562", (uint8_t)addr.address);
|
||||
#endif
|
||||
case XPOWERS_AXP192_AXP2101_ADDRESS:
|
||||
// Do we have the axp2101/192 or the TCA8418
|
||||
@@ -425,27 +413,47 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
#endif
|
||||
#if !defined(M5STACK_UNITC6L)
|
||||
case INA_ADDR:
|
||||
case INA_ADDR: // Same as SHT2X
|
||||
case INA_ADDR_ALTERNATE:
|
||||
case INA_ADDR_WAVESHARE_UPS:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
LOG_DEBUG("Register MFG_UID: 0x%x", registerValue);
|
||||
if (registerValue == 0x5449) {
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
|
||||
LOG_DEBUG("Register DIE_UID: 0x%x", registerValue);
|
||||
case INA_ADDR_WAVESHARE_UPS: {
|
||||
uint16_t mfg = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
|
||||
if (registerValue == 0x2260) {
|
||||
LOG_DEBUG("Register MFG_UID: 0x%x", mfg);
|
||||
|
||||
// Only read DIE_UID for vendors we recognize as INA-compatible to avoid
|
||||
// an extra I2C transaction + delay on other devices sharing this address.
|
||||
if (mfg == 0x5449 || mfg == 0x190F) {
|
||||
uint16_t die = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
|
||||
LOG_DEBUG("Register DIE_UID: 0x%x", die);
|
||||
|
||||
// TI INA226 or fully compatible clones (e.g. TPA626)
|
||||
if (mfg == 0x5449 && die == 0x2260) {
|
||||
logFoundDevice("INA226", (uint8_t)addr.address);
|
||||
type = INA226;
|
||||
} else {
|
||||
}
|
||||
// Silergy SQ52201 (INA226-compatible with different IDs)
|
||||
else if (mfg == 0x190F && die == 0x0000) {
|
||||
logFoundDevice("INA226 (SQ52201)", (uint8_t)addr.address);
|
||||
type = INA226;
|
||||
}
|
||||
// TI INA260
|
||||
else if (mfg == 0x5449) {
|
||||
logFoundDevice("INA260", (uint8_t)addr.address);
|
||||
type = INA260;
|
||||
}
|
||||
} else { // Assume INA219 if INA260 ID is not found
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
|
||||
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
|
||||
type = SHTXX;
|
||||
}
|
||||
#endif
|
||||
else { // Assume INA219 if none of the above ones are found
|
||||
logFoundDevice("INA219", (uint8_t)addr.address);
|
||||
type = INA219;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case INA3221_ADDR:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue);
|
||||
@@ -502,22 +510,19 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR
|
||||
case SHTXX_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHTXX_ADDR_ALT: // same as OPT3001_ADDR
|
||||
if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
|
||||
type = OPT3001;
|
||||
logFoundDevice("OPT3001", (uint8_t)addr.address);
|
||||
} else if (i2cCommandResponseLength(addr, 0x89, 6)) { // SHT4x serial number (6 bytes inc. CRC)
|
||||
type = SHT4X;
|
||||
logFoundDevice("SHT4X", (uint8_t)addr.address);
|
||||
} else {
|
||||
type = SHT31;
|
||||
logFoundDevice("SHT31", (uint8_t)addr.address);
|
||||
} else { // SHTXX
|
||||
type = SHTXX;
|
||||
logFoundDevice("SHTXX", (uint8_t)addr.address);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address)
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address)
|
||||
case RCWL9620_ADDR:
|
||||
// get MAX30102 PARTID
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1);
|
||||
@@ -578,18 +583,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
#else
|
||||
SCAN_SIMPLE_CASE(PMSA003I_ADDR, PMSA003I, "PMSA003I", (uint8_t)addr.address)
|
||||
#endif
|
||||
case MMC5983MA_ADDR:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x2F), 1);
|
||||
if (registerValue == 0x30) {
|
||||
type = MMC5983MA;
|
||||
logFoundDevice("MMC5983MA", (uint8_t)addr.address);
|
||||
#ifdef HAS_LP5562
|
||||
} else {
|
||||
type = LP5562;
|
||||
logFoundDevice("LP5562", (uint8_t)addr.address);
|
||||
#endif
|
||||
}
|
||||
break;
|
||||
case BMA423_ADDR: // this can also be LIS3DH_ADDR_ALT
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0F), 2);
|
||||
if (registerValue == 0x3300 || registerValue == 0x3333) { // RAK4631 WisBlock has LIS3DH register at 0x3333
|
||||
@@ -744,8 +737,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
}
|
||||
break;
|
||||
|
||||
case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT, ICM42607P_ADDR_ALT, and SEN5X_ADDR
|
||||
case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR, and ICM42607P_ADDR
|
||||
case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT, and SEN5X_ADDR
|
||||
case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1);
|
||||
#ifdef HAS_ICM20948
|
||||
type = ICM20948;
|
||||
@@ -765,12 +758,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
break;
|
||||
} else {
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x75), 1); // WHO_AM_I
|
||||
if (registerValue == 0x60) {
|
||||
type = ICM42607P;
|
||||
logFoundDevice("ICM-42607-P", (uint8_t)addr.address);
|
||||
break;
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
String prod = "";
|
||||
prod = readSEN5xProductName(i2cBus, addr.address);
|
||||
if (prod.startsWith("SEN55")) {
|
||||
@@ -786,6 +774,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("Sensirion SEN50", addr.address);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
if (addr.address == BMX160_ADDR) {
|
||||
type = BMX160;
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
|
||||
@@ -53,7 +53,7 @@ class ScanI2CTwoWire : public ScanI2C
|
||||
|
||||
concurrency::Lock lock;
|
||||
|
||||
uint16_t getRegisterValue(const RegisterLocation &, ResponseWidth, bool = false) const;
|
||||
uint16_t getRegisterValue(const RegisterLocation &, ResponseWidth, bool) const;
|
||||
|
||||
bool i2cCommandResponseLength(DeviceAddress addr, uint16_t command, uint8_t expectedLength) const;
|
||||
|
||||
|
||||
+11
-5
@@ -103,6 +103,14 @@ static int32_t gpsSwitch()
|
||||
if (gps) {
|
||||
int currentState = digitalRead(PIN_GPS_SWITCH);
|
||||
|
||||
// Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS.
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
|
||||
gps->disable();
|
||||
lastState = currentState;
|
||||
firstrun = false;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
// if the switch is set to zero, disable the GPS Thread
|
||||
if (firstrun)
|
||||
if (currentState == LOW)
|
||||
@@ -832,10 +840,7 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
|
||||
switch (newState) {
|
||||
case GPS_ACTIVE:
|
||||
case GPS_IDLE:
|
||||
if (oldState == GPS_ACTIVE)
|
||||
break;
|
||||
gotTime = false;
|
||||
if (oldState == GPS_IDLE) // If hardware already awake, no changes needed
|
||||
if (oldState == GPS_ACTIVE || oldState == GPS_IDLE) // If hardware already awake, no changes needed
|
||||
break;
|
||||
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
|
||||
clearBuffer();
|
||||
@@ -1145,7 +1150,8 @@ int32_t GPS::runOnce()
|
||||
// if gps_update_interval is <=10s, GPS never goes off, so we treat that differently
|
||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
||||
|
||||
// 1. Got a time for the first time this cycle
|
||||
// 1. Got a time for the first time
|
||||
bool gotTime = (getRTCQuality() >= RTCQualityGPS);
|
||||
if (!gotTime && lookForTime()) { // Note: we count on this && short-circuiting and not resetting the RTC time
|
||||
gotTime = true;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ class GPS : private concurrency::OSThread
|
||||
uint32_t lastChecksumFailCount = 0;
|
||||
uint8_t currentStep = 0;
|
||||
int32_t currentDelay = 2000;
|
||||
bool gotTime = false;
|
||||
|
||||
#ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS
|
||||
// (20210908) TinyGps++ can only read the GPGSA "FIX TYPE" field
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user