mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-16 16:41:33 +10:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377ba3deb9 | ||
|
|
b10d1d5d2c | ||
|
|
ac23497a49 | ||
|
|
64eded235f | ||
|
|
66d404932d | ||
|
|
ae63e9b80f | ||
|
|
8a82f5317b | ||
|
|
82aefd1af1 | ||
|
|
c4daf0ae47 | ||
|
|
0f9eb86830 | ||
|
|
c7c7e628c5 | ||
|
|
e2aa44ec54 | ||
|
|
40e191ac58 | ||
|
|
c417ba8e72 | ||
|
|
0832330327 | ||
|
|
2e343a156d | ||
|
|
6fde0f949b | ||
|
|
8fa7ae40a7 | ||
|
|
8a8e4b841a | ||
|
|
0c1457b5dc | ||
|
|
eda3a79a97 | ||
|
|
da34334bb2 | ||
|
|
af3739fd63 | ||
|
|
254fc47b2c | ||
|
|
0148a89ddb | ||
|
|
fb119a9ff3 | ||
|
|
b9d6903ddb | ||
|
|
6199faacf1 | ||
|
|
ef2912672b | ||
|
|
e8ecd0dda2 | ||
|
|
5025ca45df | ||
|
|
62bb2c0c3c | ||
|
|
fc5556b8e6 | ||
|
|
502c5af524 | ||
|
|
05707079bd | ||
|
|
1c05633fcd | ||
|
|
fce419b335 | ||
|
|
1ae4a538f5 | ||
|
|
c756bbe2c1 | ||
|
|
4c3ba612bb | ||
|
|
59025e4820 | ||
|
|
0a7b3c723e | ||
|
|
cd5d608e8d | ||
|
|
7f5184281d | ||
|
|
4446b0f1a2 | ||
|
|
64fd61706d | ||
|
|
8877608858 | ||
|
|
dfcb685963 | ||
|
|
9bc25b34fd | ||
|
|
33319aa4e2 | ||
|
|
d79e62fd2a | ||
|
|
f6a954b97e | ||
|
|
10a7f1042b | ||
|
|
b4234b7f11 | ||
|
|
5512185cfe | ||
|
|
a8a785bbb7 | ||
|
|
0f854862e7 | ||
|
|
b246bcd72e | ||
|
|
33e2bb70e6 | ||
|
|
220bb4d186 | ||
|
|
6e810741f3 | ||
|
|
603cce2988 | ||
|
|
d559af8477 |
@@ -49,11 +49,17 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
|||||||
- Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh)
|
- Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh)
|
||||||
- Do the primary channel NAMES match? Mismatch = different PSK = no decode.
|
- Do the primary channel NAMES match? Mismatch = different PSK = no decode.
|
||||||
|
|
||||||
7. **Suggest next actions only for specific, recognisable failure modes**:
|
7. **Recorder slice (cheap, always available).** The mcp-server runs an autouse log recorder that's been collecting from every connected device. Pull two short slices to surface anything weird that's already happened:
|
||||||
|
- `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` — recent firmware errors. If empty, say "no recent errors"; don't manufacture concern.
|
||||||
|
- `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` — heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap.
|
||||||
|
- If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet — enable `set_debug_log_api(True)` to populate" and skip this step gracefully.
|
||||||
|
|
||||||
|
8. **Suggest next actions only for specific, recognisable failure modes**:
|
||||||
- Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path."
|
- Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path."
|
||||||
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
|
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
|
||||||
- Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
|
- Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
|
||||||
- CP2102-wedged-driver on macOS → see the note in `run-tests.sh`.
|
- CP2102-wedged-driver on macOS → see the note in `run-tests.sh`.
|
||||||
|
- Heap slope strongly negative → "run `/leakhunt window=6h` for a full timeline + classification."
|
||||||
|
|
||||||
## What NOT to do
|
## What NOT to do
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
description: Hunt for memory leaks (and other slow degradations) by reading the persistent recorder's heap timeline + log slice over a window
|
||||||
|
argument-hint: [window=1h] [field=free_heap] [variant=local]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD029 -->
|
||||||
|
|
||||||
|
# `/leakhunt` — read the recorder, classify a memory leak
|
||||||
|
|
||||||
|
Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags — the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`.
|
||||||
|
|
||||||
|
## Two signal paths — pick the right one
|
||||||
|
|
||||||
|
| Path | Build flag | Cadence | Per-thread attribution | Cost |
|
||||||
|
| --------------------- | ---------------- | -------------- | ---------------------- | ------------------------- |
|
||||||
|
| LocalStats packet | (default) | ~60 s | No | Free — always on |
|
||||||
|
| `[heap N]` log prefix | `-DDEBUG_HEAP=1` | every log line | Yes (Thread X leaked) | Bigger flash + log volume |
|
||||||
|
|
||||||
|
Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** — when the slope is real and you need to know which thread is leaking.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
1. **Parse `$ARGUMENTS`**: optional `window` (default `1h`, accepts `30m`/`6h`/`-3d`/etc.), optional `field` (default `free_heap`; alternates: `total_heap`, `battery_level`, anything in the LocalStats variant), optional `variant` (default `local`; alternates: `device`, `environment`, `power`, `airQuality`, `health`).
|
||||||
|
|
||||||
|
2. **Verify the recorder is alive** — call `mcp__meshtastic__recorder_status`. Check:
|
||||||
|
- `running == True`
|
||||||
|
- `files.telemetry.lines > 0` (at least one telemetry packet recorded — if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval)
|
||||||
|
- `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent — log that, not "leak detected")
|
||||||
|
|
||||||
|
3. **Detect whether DEBUG_HEAP is active** — `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path.
|
||||||
|
|
||||||
|
4. **Pull the timeline** — `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read:
|
||||||
|
- `samples` — how many raw points contributed
|
||||||
|
- `min`, `max` — total swing
|
||||||
|
- `slope_per_min` — units per minute (linear regression over the whole window)
|
||||||
|
|
||||||
|
5. **Pull the log context for the same window** — `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM).
|
||||||
|
|
||||||
|
6. **Pull marker events** so we know if the operator labeled phases — `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot.
|
||||||
|
|
||||||
|
6a. **(DEBUG_HEAP only) Per-thread attribution** — `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too — a thread with 50× small leaks is different from 1× big leak.
|
||||||
|
|
||||||
|
7. **Classify** based on what the data says, NOT on what you wish it said. Use these rules in order:
|
||||||
|
- **Insufficient data** (< 5 samples): say so. Suggest a longer window or longer wait. Stop.
|
||||||
|
- **Reboot mid-window**: if any `connection_lost` event is present AND `free_heap` jumped UP at that timestamp, the device rebooted. Note it; pre-reboot trend may be a leak but you only have part of the curve.
|
||||||
|
- **OOM-imminent**: any `Alloc an err=` or `out of memory` line in the log slice. This trumps everything; flag urgently.
|
||||||
|
- **Slow leak**: `slope_per_min < -50` AND `max - min > 1000` AND no reboot. The heap is monotonically (or near-monotonically) declining. Estimate time-to-zero: `min / -slope_per_min` minutes. Surface it.
|
||||||
|
- **Fragmentation suspect**: `slope_per_min` close to zero (|x| < 50) BUT min trends down across the window AND the log slice shows `Alloc an err` warnings WITHOUT total OOM. Means free total is OK but largest contiguous block is shrinking. Recommend a `DEBUG_HEAP` build to confirm.
|
||||||
|
- **Steady**: |slope_per_min| < 50, no error lines. Heap is fine.
|
||||||
|
- **Recovery curve**: slope is POSITIVE — heap recovered. Either a workload completed or GC fired. Note it; not a leak.
|
||||||
|
|
||||||
|
8. **Report**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/leakhunt window=6h field=free_heap variant=local
|
||||||
|
────────────────────────────────────────────────────
|
||||||
|
recorder : running, telem last_ts 8s ago
|
||||||
|
build : DEBUG_HEAP=ON (per-line prefix detected)
|
||||||
|
samples : 14,200 over 6h (cadence ~1.5s, log-line synth)
|
||||||
|
free_heap : min 92,344 / max 124,008 / range 31,664
|
||||||
|
slope : -82 bytes/min (negative — heap declining)
|
||||||
|
reboots : none in window
|
||||||
|
OOM events : none
|
||||||
|
error lines : 3× "Alloc an err=ESP_ERR_NO_MEM" at +4h12m, +5h08m, +5h44m
|
||||||
|
thread leaks : (DEBUG_HEAP) MeshPacket -3,124 B over 18 events
|
||||||
|
Router -1,408 B over 4 events
|
||||||
|
others -240 B
|
||||||
|
verdict : SLOW LEAK — primary suspect MeshPacket thread
|
||||||
|
est. time-to-OOM: ~1,127 min (~18.8 h) at current slope
|
||||||
|
evidence : (3 log line citations with uptimes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: **what to do next.**
|
||||||
|
- SLOW LEAK, **DEBUG_HEAP off** → recommend rebuilding with the flag and re-running this skill. Concrete one-liner the operator can copy:
|
||||||
|
```text
|
||||||
|
mcp__meshtastic__build(env="<env>", build_flags={"DEBUG_HEAP": 1})
|
||||||
|
mcp__meshtastic__pio_flash(env="<env>", port="<port>", confirm=True)
|
||||||
|
```
|
||||||
|
After flash, set debug_log_api back on and wait one window; re-run `/leakhunt`.
|
||||||
|
- SLOW LEAK, **DEBUG_HEAP on** → cite the top-leaking thread name from step 6a. Point at the corresponding source file (`grep -rn "ThreadName(\"<name>\")" src/`); the operator decides what to fix.
|
||||||
|
- FRAGMENTATION SUSPECT → propose pre-allocating any per-packet buffers; or rebuilding with `CONFIG_HEAP_TASK_TRACKING=y` on ESP32 to see who's holding the largest blocks.
|
||||||
|
- OOM-IMMINENT → flag for immediate attention; don't wait for the next telemetry interval.
|
||||||
|
- STEADY → say so; stop. Don't invent problems.
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- Don't assume a leak from a single dip. LocalStats fires every ~60 s and the firmware naturally allocates+frees on each broadcast cycle; one packet sees the trough. Look at the slope, not the deltas.
|
||||||
|
- Don't recommend code changes. This skill diagnoses; the operator decides what to fix.
|
||||||
|
- Don't enable `set_debug_log_api` automatically — if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run.
|
||||||
|
- Don't run heavy workloads to "trigger the leak." The recorder is passive; we read what's there.
|
||||||
|
|
||||||
|
## Companion: `mark_event` for stress runs
|
||||||
|
|
||||||
|
If the operator wants to test under stimulus (e.g. blast 50 broadcasts and see what the heap does), they can frame the experiment with markers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mark_event("burst-start")
|
||||||
|
… run the workload …
|
||||||
|
mark_event("burst-end")
|
||||||
|
/leakhunt window=15m
|
||||||
|
```
|
||||||
|
|
||||||
|
The markers land in both `events.jsonl` and `logs.jsonl`, so the report can show "free_heap dipped 8 KB during the burst window, recovered to baseline within 2 LocalStats cycles" → not a leak.
|
||||||
@@ -3,6 +3,8 @@ description: Re-run a specific test N times in isolation to triage flakes, diff
|
|||||||
argument-hint: <test-node-id> [count=5]
|
argument-hint: <test-node-id> [count=5]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD029 -->
|
||||||
|
|
||||||
# `/repro` — flakiness triage for one test
|
# `/repro` — flakiness triage for one test
|
||||||
|
|
||||||
Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y."
|
Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y."
|
||||||
@@ -40,6 +42,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
|
|||||||
|
|
||||||
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps.
|
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps.
|
||||||
|
|
||||||
|
5a. **Archive recorder slices per attempt** (no extra device interaction; the recorder runs autouse). Right after each attempt finishes, capture its `(start_ts, end_ts)` and call `mcp__meshtastic__recorder_export(start=<start>, end=<end>, dest_dir="mcp-server/tests/repro_artifacts/<safe-test-id>/attempt_<n>/")`. This drops a `logs.jsonl`, `telemetry.jsonl`, `packets.jsonl`, and `events.jsonl` snapshot scoped to the attempt window. Use these for cross-attempt diffs in step 5: `jq '.line' logs.jsonl` is faster than re-running the test, and the telemetry slice lets you compare heap behavior across attempts.
|
||||||
|
|
||||||
6. **Classify the flake** into one of:
|
6. **Classify the flake** into one of:
|
||||||
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
|
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
|
||||||
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
|
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
|
||||||
|
|||||||
@@ -15,10 +15,14 @@
|
|||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"ms-vscode.cpptools",
|
"ms-vscode.cpptools",
|
||||||
"platformio.platformio-ide",
|
"Jason2866.esp-decoder",
|
||||||
|
"pioarduino.pioarduino-ide",
|
||||||
"Trunk.io"
|
"Trunk.io"
|
||||||
],
|
],
|
||||||
"unwantedRecommendations": ["ms-azuretools.vscode-docker"],
|
"unwantedRecommendations": [
|
||||||
|
"ms-azuretools.vscode-docker",
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"extensions.ignoreRecommendations": true
|
"extensions.ignoreRecommendations": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ firmware/
|
|||||||
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
||||||
- Use `assert()` for invariants that should never fail
|
- Use `assert()` for invariants that should never fail
|
||||||
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
|
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
|
||||||
|
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||||
|
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
# trunk-ignore(checkov/CKV_GHA_7)
|
# 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:
|
arch:
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- all
|
|
||||||
- esp32
|
- esp32
|
||||||
- esp32s3
|
- esp32s3
|
||||||
- esp32c3
|
- esp32c3
|
||||||
@@ -20,18 +15,32 @@ on:
|
|||||||
- rp2040
|
- rp2040
|
||||||
- rp2350
|
- rp2350
|
||||||
- stm32
|
- stm32
|
||||||
description: Choose an arch to limit the search, or 'all' to search all architectures.
|
target:
|
||||||
default: all
|
type: string
|
||||||
|
required: false
|
||||||
|
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||||
|
# find-target:
|
||||||
|
# type: boolean
|
||||||
|
# default: true
|
||||||
|
# description: 'Find the available targets'
|
||||||
|
|
||||||
permissions: read-all
|
permissions: read-all
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
find-targets:
|
find-targets:
|
||||||
|
if: ${{ inputs.target == '' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch:
|
arch:
|
||||||
- all
|
- esp32
|
||||||
|
- esp32s3
|
||||||
|
- esp32c3
|
||||||
|
- esp32c6
|
||||||
|
- nrf52840
|
||||||
|
- rp2040
|
||||||
|
- rp2350
|
||||||
|
- stm32
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -42,37 +51,14 @@ jobs:
|
|||||||
- run: pip install -U platformio
|
- run: pip install -U platformio
|
||||||
- name: Generate matrix
|
- name: Generate matrix
|
||||||
id: jsonStep
|
id: jsonStep
|
||||||
env:
|
|
||||||
BUILDTARGET: ${{ inputs.target }}
|
|
||||||
MATRIXARCH: ${{ inputs.arch }}
|
|
||||||
run: |
|
run: |
|
||||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
|
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
|
||||||
if [ "$BUILDTARGET" = "" ]; then
|
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
|
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
echo "Targets:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
|
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $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:
|
version:
|
||||||
if: ${{ inputs.target != '' }}
|
if: ${{ inputs.target != '' }}
|
||||||
@@ -92,12 +78,12 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
||||||
needs: [version, find-targets]
|
needs: [version]
|
||||||
uses: ./.github/workflows/build_firmware.yml
|
uses: ./.github/workflows/build_firmware.yml
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.version.outputs.long }}
|
version: ${{ needs.version.outputs.long }}
|
||||||
pio_env: ${{ inputs.target }}
|
pio_env: ${{ inputs.target }}
|
||||||
platform: ${{ needs.find-targets.outputs.arch }}
|
platform: ${{ inputs.arch }}
|
||||||
|
|
||||||
gather-artifacts:
|
gather-artifacts:
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -86,13 +86,7 @@ jobs:
|
|||||||
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
||||||
|
|
||||||
- name: PlatformIO Tests
|
- name: PlatformIO Tests
|
||||||
run: |
|
run: platformio test -e coverage -v --junit-output-path testreport.xml
|
||||||
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
|
- name: Save test results
|
||||||
if: always() # run this step even if previous step failed
|
if: always() # run this step even if previous step failed
|
||||||
|
|||||||
+5
-5
@@ -4,19 +4,19 @@ cli:
|
|||||||
plugins:
|
plugins:
|
||||||
sources:
|
sources:
|
||||||
- id: trunk
|
- id: trunk
|
||||||
ref: v1.8.0
|
ref: v1.10.0
|
||||||
uri: https://github.com/trunk-io/plugins
|
uri: https://github.com/trunk-io/plugins
|
||||||
lint:
|
lint:
|
||||||
enabled:
|
enabled:
|
||||||
- checkov@3.2.526
|
- checkov@3.2.529
|
||||||
- renovate@43.150.0
|
- renovate@43.150.0
|
||||||
- prettier@3.8.3
|
- prettier@3.8.3
|
||||||
- trufflehog@3.95.2
|
- trufflehog@3.95.3
|
||||||
- yamllint@1.38.0
|
- yamllint@1.38.0
|
||||||
- bandit@1.9.4
|
- bandit@1.9.4
|
||||||
- trivy@0.70.0
|
- trivy@0.70.0
|
||||||
- taplo@0.10.0
|
- taplo@0.10.0
|
||||||
- ruff@0.15.12
|
- ruff@0.15.13
|
||||||
- isort@8.0.1
|
- isort@8.0.1
|
||||||
- markdownlint@0.48.0
|
- markdownlint@0.48.0
|
||||||
- oxipng@10.1.1
|
- oxipng@10.1.1
|
||||||
@@ -26,7 +26,7 @@ lint:
|
|||||||
- hadolint@2.14.0
|
- hadolint@2.14.0
|
||||||
- shfmt@3.6.0
|
- shfmt@3.6.0
|
||||||
- shellcheck@0.11.0
|
- shellcheck@0.11.0
|
||||||
- black@26.3.1
|
- black@26.5.1
|
||||||
- git-diff-check
|
- git-diff-check
|
||||||
- gitleaks@8.30.1
|
- gitleaks@8.30.1
|
||||||
- clang-format@16.0.3
|
- clang-format@16.0.3
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
|||||||
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
||||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
||||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||||
|
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||||
|
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||||
|
|
||||||
## Typical agent workflows
|
## Typical agent workflows
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -38,4 +38,4 @@ cp bin/device-install.* $OUTDIR/
|
|||||||
cp bin/device-update.* $OUTDIR/
|
cp bin/device-update.* $OUTDIR/
|
||||||
|
|
||||||
echo "Copying manifest"
|
echo "Copying manifest"
|
||||||
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json
|
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
Lora:
|
|
||||||
## Ebyte E80-900M22S
|
|
||||||
## This is a bit experimental
|
|
||||||
##
|
|
||||||
##
|
|
||||||
Module: lr1121
|
|
||||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
|
||||||
DIO3_TCXO_VOLTAGE: 1.8
|
|
||||||
CS: 16 #pin6 / GPIO48 1C0
|
|
||||||
IRQ: 23 #pin17 / GPIO55 1C7
|
|
||||||
Busy: 22 #pin16 / GPIO54 1C6
|
|
||||||
Reset: 25 #pin13 / GPIO57 1D1
|
|
||||||
|
|
||||||
|
|
||||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
|
||||||
spiSpeed: 2000000
|
|
||||||
|
|
||||||
rfswitch_table:
|
|
||||||
pins: [DIO5, DIO6, DIO7]
|
|
||||||
MODE_STBY: [LOW, LOW, LOW]
|
|
||||||
MODE_RX: [LOW, HIGH, LOW]
|
|
||||||
MODE_TX: [HIGH, HIGH, LOW]
|
|
||||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
|
||||||
MODE_TX_HF: [LOW, LOW, LOW]
|
|
||||||
MODE_GNSS: [LOW, LOW, HIGH]
|
|
||||||
MODE_WIFI: [LOW, LOW, LOW]
|
|
||||||
|
|
||||||
General:
|
|
||||||
MACAddressSource: eth0
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
Lora:
|
|
||||||
## Ebyte E80-900M22S
|
|
||||||
## This is a bit experimental
|
|
||||||
##
|
|
||||||
##
|
|
||||||
Module: lr1121
|
|
||||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
|
||||||
DIO3_TCXO_VOLTAGE: 1.8
|
|
||||||
CS: 16 #pin6 / GPIO48 1C0
|
|
||||||
IRQ: 23 #pin17 / GPIO55 1C7
|
|
||||||
Busy: 22 #pin16 / GPIO54 1C6
|
|
||||||
Reset: 25 #pin13 / GPIO57 1D1
|
|
||||||
|
|
||||||
|
|
||||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
|
||||||
spiSpeed: 2000000
|
|
||||||
|
|
||||||
rfswitch_table:
|
|
||||||
pins:
|
|
||||||
- DIO5
|
|
||||||
- DIO6
|
|
||||||
MODE_STBY:
|
|
||||||
- LOW
|
|
||||||
- LOW
|
|
||||||
MODE_RX:
|
|
||||||
- HIGH
|
|
||||||
- LOW
|
|
||||||
MODE_TX:
|
|
||||||
- HIGH
|
|
||||||
- HIGH
|
|
||||||
MODE_TX_HP:
|
|
||||||
- LOW
|
|
||||||
- HIGH
|
|
||||||
MODE_TX_HF:
|
|
||||||
- LOW
|
|
||||||
- LOW
|
|
||||||
MODE_GNSS:
|
|
||||||
- LOW
|
|
||||||
- LOW
|
|
||||||
MODE_WIFI:
|
|
||||||
- LOW
|
|
||||||
- LOW
|
|
||||||
|
|
||||||
General:
|
|
||||||
MACAddressSource: eth0
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
Lora:
|
|
||||||
## Ebyte E80-900M22S
|
|
||||||
## This is a bit experimental
|
|
||||||
##
|
|
||||||
##
|
|
||||||
Module: lr1121
|
|
||||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
|
||||||
DIO3_TCXO_VOLTAGE: 1.8
|
|
||||||
CS: 16 #pin6 / GPIO48 1C0
|
|
||||||
IRQ: 23 #pin17 / GPIO55 1C7
|
|
||||||
Busy: 22 #pin16 / GPIO54 1C6
|
|
||||||
Reset: 25 #pin13 / GPIO57 1D1
|
|
||||||
|
|
||||||
|
|
||||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
|
||||||
spiSpeed: 2000000
|
|
||||||
|
|
||||||
rfswitch_table:
|
|
||||||
pins: [DIO5, DIO6, DIO7]
|
|
||||||
MODE_STBY: [LOW, LOW, LOW]
|
|
||||||
MODE_RX: [LOW, LOW, LOW]
|
|
||||||
MODE_TX: [LOW, HIGH, LOW]
|
|
||||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
|
||||||
# MODE_TX_HF: []
|
|
||||||
# MODE_GNSS: []
|
|
||||||
MODE_WIFI: [LOW, LOW, LOW]
|
|
||||||
|
|
||||||
General:
|
|
||||||
MACAddressSource: eth0
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
</screenshots>
|
</screenshots>
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
|
<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>
|
||||||
<release version="2.7.23" date="2026-04-14">
|
<release version="2.7.23" date="2026-04-14">
|
||||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23</url>
|
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23</url>
|
||||||
</release>
|
</release>
|
||||||
|
|||||||
@@ -293,12 +293,9 @@ if ("HAS_TFT", 1) in env.get("CPPDEFINES", []):
|
|||||||
board_arch = infer_architecture(env.BoardConfig())
|
board_arch = infer_architecture(env.BoardConfig())
|
||||||
should_skip_manifest = board_arch is None
|
should_skip_manifest = board_arch is None
|
||||||
|
|
||||||
# Most platforms can generate the manifest as part of the default 'buildprog' target.
|
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
|
||||||
# Typically this passes success/failure properly.
|
mtjson_deps = [] if should_skip_manifest else ["buildprog"]
|
||||||
mtjson_deps = ["buildprog"]
|
if not should_skip_manifest and platform.name == "espressif32":
|
||||||
if platform.name == "espressif32":
|
|
||||||
# On ESP32, we need to explicitly depend upon the binary to prevent fake-success upon failure.
|
|
||||||
mtjson_deps = ["$BUILD_DIR/${PROGNAME}.bin"]
|
|
||||||
# Build littlefs image as part of mtjson target
|
# Build littlefs image as part of mtjson target
|
||||||
# Equivalent to `pio run -t buildfs`
|
# Equivalent to `pio run -t buildfs`
|
||||||
target_lfs = env.DataToBin(
|
target_lfs = env.DataToBin(
|
||||||
@@ -312,8 +309,7 @@ if should_skip_manifest:
|
|||||||
|
|
||||||
env.AddCustomTarget(
|
env.AddCustomTarget(
|
||||||
name="mtjson",
|
name="mtjson",
|
||||||
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
|
dependencies=mtjson_deps,
|
||||||
dependencies=[],
|
|
||||||
actions=[skip_manifest],
|
actions=[skip_manifest],
|
||||||
title="Meshtastic Manifest (skipped)",
|
title="Meshtastic Manifest (skipped)",
|
||||||
description="mtjson generation is skipped for native environments",
|
description="mtjson generation is skipped for native environments",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-D CDEBYTE_EORA_S3",
|
"-D CDEBYTE_EORA_S3",
|
||||||
"-D ARDUINO_USB_CDC_ON_BOOT=1",
|
"-D ARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-D ARDUINO_USB_MODE=1",
|
"-D ARDUINO_USB_MODE=0",
|
||||||
"-D ARDUINO_RUNNING_CORE=1",
|
"-D ARDUINO_RUNNING_CORE=1",
|
||||||
"-D ARDUINO_EVENT_RUNNING_CORE=1",
|
"-D ARDUINO_EVENT_RUNNING_CORE=1",
|
||||||
"-D BOARD_HAS_PSRAM"
|
"-D BOARD_HAS_PSRAM"
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"arduino": {
|
||||||
|
"ldscript": "esp32s3_out.ld",
|
||||||
|
"memory_type": "qio_opi"
|
||||||
|
},
|
||||||
|
"core": "esp32",
|
||||||
|
"extra_flags": [
|
||||||
|
"-D BOARD_HAS_PSRAM",
|
||||||
|
"-D ARDUINO_USB_CDC_ON_BOOT=0",
|
||||||
|
"-D ARDUINO_USB_MODE=0",
|
||||||
|
"-D ARDUINO_RUNNING_CORE=1",
|
||||||
|
"-D ARDUINO_EVENT_RUNNING_CORE=0"
|
||||||
|
],
|
||||||
|
"f_cpu": "240000000L",
|
||||||
|
"f_flash": "80000000L",
|
||||||
|
"flash_mode": "qio",
|
||||||
|
"psram_type": "qio_opi",
|
||||||
|
"hwids": [["0x303A", "0x1001"]],
|
||||||
|
"mcu": "esp32s3",
|
||||||
|
"variant": "ELECROW-ThinkNode-M7"
|
||||||
|
},
|
||||||
|
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||||
|
"debug": {
|
||||||
|
"default_tool": "esp-builtin",
|
||||||
|
"onboard_tools": ["esp-builtin"],
|
||||||
|
"openocd_target": "esp32s3.cfg"
|
||||||
|
},
|
||||||
|
"frameworks": ["arduino", "espidf"],
|
||||||
|
"name": "ELECROW ThinkNode M7",
|
||||||
|
"upload": {
|
||||||
|
"flash_size": "8MB",
|
||||||
|
"maximum_ram_size": 524288,
|
||||||
|
"maximum_size": 8388608,
|
||||||
|
"use_1200bps_touch": true,
|
||||||
|
"wait_for_upload_port": true,
|
||||||
|
"require_upload_port": true,
|
||||||
|
"speed": 921600
|
||||||
|
},
|
||||||
|
"url": "https://www.elecrow.com",
|
||||||
|
"vendor": "ELECROW"
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
||||||
"-DBOARD_HAS_PSRAM"
|
"-DBOARD_HAS_PSRAM"
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"build": {
|
|
||||||
"core": "esp32",
|
|
||||||
"extra_flags": ["-DBOARD_HAS_PSRAM"],
|
|
||||||
"f_cpu": "360000000L",
|
|
||||||
"f_flash": "80000000L",
|
|
||||||
"f_psram": "200000000L",
|
|
||||||
"flash_mode": "qio",
|
|
||||||
"hwids": [["0x1A86", "0x7522"]],
|
|
||||||
"mcu": "esp32p4",
|
|
||||||
"chip_variant": "esp32p4",
|
|
||||||
"variant": "esp32p4"
|
|
||||||
},
|
|
||||||
"arduino": {
|
|
||||||
"partitions": "default_16MB.csv"
|
|
||||||
},
|
|
||||||
"connectivity": ["bluetooth", "openthread"],
|
|
||||||
"debug": {
|
|
||||||
"openocd_target": "esp32p4.cfg"
|
|
||||||
},
|
|
||||||
"frameworks": ["arduino", "espidf"],
|
|
||||||
"name": "CrowPanel Advanced ESP32-P4 HMI AI Display",
|
|
||||||
"upload": {
|
|
||||||
"flash_size": "16MB",
|
|
||||||
"maximum_ram_size": 512000,
|
|
||||||
"maximum_size": 16777216,
|
|
||||||
"require_upload_port": true,
|
|
||||||
"speed": 1500000
|
|
||||||
},
|
|
||||||
"url": "https://www.elecrow.com/crowpanel-advanced-5inch-esp32-p4-hmi-ai-display-800x480-ips-touch-screen-with-wifi-6.html",
|
|
||||||
"vendor": "Elecrow"
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DHELTEC_WIRELESS_TRACKER",
|
"-DHELTEC_WIRELESS_TRACKER",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=0"
|
"-DARDUINO_EVENT_RUNNING_CORE=0"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DLILYGO_TBEAM_1W",
|
"-DLILYGO_TBEAM_1W",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DLILYGO_TBEAM_S3_CORE",
|
"-DLILYGO_TBEAM_S3_CORE",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DLILYGO_T3S3_V1",
|
"-DLILYGO_T3S3_V1",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
||||||
"-DBOARD_HAS_PSRAM"
|
"-DBOARD_HAS_PSRAM"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DUNPHONE_SPIN=9",
|
"-DUNPHONE_SPIN=9",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=0",
|
||||||
"-DARDUINO_RUNNING_CORE=1",
|
"-DARDUINO_RUNNING_CORE=1",
|
||||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||||
],
|
],
|
||||||
|
|||||||
Vendored
+6
@@ -1,3 +1,9 @@
|
|||||||
|
meshtasticd (2.7.24.0) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Version 2.7.24
|
||||||
|
|
||||||
|
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Fri, 08 May 2026 10:44:12 +0000
|
||||||
|
|
||||||
meshtasticd (2.7.23.0) unstable; urgency=medium
|
meshtasticd (2.7.23.0) unstable; urgency=medium
|
||||||
|
|
||||||
* Version 2.7.23
|
* Version 2.7.23
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# Name, Type, SubType, Offset, Size, Flags
|
|
||||||
nvs, data, nvs, 0x9000, 0x5000,
|
|
||||||
otadata, data, ota, 0xe000, 0x2000,
|
|
||||||
app0, app, ota_0, 0x10000, 0x640000,
|
|
||||||
app1, app, ota_1, 0x650000,0x640000,
|
|
||||||
spiffs, data, spiffs, 0xc90000,0x360000,
|
|
||||||
coredump, data, coredump,0xFF0000,0x10000,
|
|
||||||
|
@@ -1,7 +0,0 @@
|
|||||||
# Name, Type, SubType, Offset, Size, Flags
|
|
||||||
nvs, data, nvs, 0x9000, 0x5000,
|
|
||||||
otadata, data, ota, 0xe000, 0x2000,
|
|
||||||
app0, app, ota_0, 0x10000, 0x330000,
|
|
||||||
app1, app, ota_1, 0x340000,0x330000,
|
|
||||||
spiffs, data, spiffs, 0x670000,0x180000,
|
|
||||||
coredump, data, coredump,0x7F0000,0x10000,
|
|
||||||
|
@@ -70,6 +70,17 @@ def esp32_create_combined_bin(source, target, env):
|
|||||||
|
|
||||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_create_combined_bin)
|
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_create_combined_bin)
|
||||||
|
|
||||||
# Enable Newlib Nano formatting to save space
|
esp32_kind = env.GetProjectOption("custom_esp32_kind")
|
||||||
# ...but allow printf float support (compromise)
|
if esp32_kind == "esp32":
|
||||||
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
|
# Free up some IRAM by removing auxiliary SPI flash chip drivers.
|
||||||
|
# Wrapped stub symbols are defined in src/platform/esp32/iram-quirk.c.
|
||||||
|
env.Append(
|
||||||
|
LINKFLAGS=[
|
||||||
|
"-Wl,--wrap=esp_flash_chip_gd",
|
||||||
|
"-Wl,--wrap=esp_flash_chip_issi",
|
||||||
|
"-Wl,--wrap=esp_flash_chip_winbond",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# For newer ESP32 targets, using newlib nano works better.
|
||||||
|
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# trunk-ignore-all(ruff/F821)
|
|
||||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
|
||||||
|
|
||||||
# force linker response file instead of command line arguments
|
|
||||||
|
|
||||||
Import("env")
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_with_tempfile(command_key):
|
|
||||||
command = env.get(command_key)
|
|
||||||
if not command or not isinstance(command, str):
|
|
||||||
return
|
|
||||||
if "TEMPFILE(" in command:
|
|
||||||
return
|
|
||||||
env.Replace(**{command_key: "${TEMPFILE('%s')}" % command})
|
|
||||||
|
|
||||||
|
|
||||||
# Force SCons to spill long commands into response files on this target.
|
|
||||||
env.Replace(MAXLINELENGTH=8192)
|
|
||||||
|
|
||||||
for key in ("LINKCOM", "CXXLINKCOM", "SHLINKCOM", "SHCXXLINKCOM"):
|
|
||||||
wrap_with_tempfile(key)
|
|
||||||
@@ -7,6 +7,12 @@ __pycache__/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# Persistent device-log capture (recorder + Datadog cursor).
|
||||||
|
# Cross-session JSONL streams written by the autouse Recorder singleton
|
||||||
|
# (see src/meshtastic_mcp/recorder/). Lives outside tests/ so the pytest
|
||||||
|
# fixture truncate doesn't touch it.
|
||||||
|
.mtlog/
|
||||||
|
|
||||||
# Test harness artifacts
|
# Test harness artifacts
|
||||||
tests/report.html
|
tests/report.html
|
||||||
tests/junit.xml
|
tests/junit.xml
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
{
|
||||||
|
"title": "Meshtastic Firmware — Recorder Stream",
|
||||||
|
"description": "Live view of `.mtlog/` streams shipped by `mtlog_to_datadog.py`. Heap, packet volume, log levels, errors. One row per port.",
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Free heap (bytes)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"show_legend": true,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "free_heap",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.local.heap_free_bytes{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": { "label": "bytes" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Heap slope (bytes/min) — last 1h",
|
||||||
|
"type": "query_value",
|
||||||
|
"precision": 0,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "slope",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "derivative(avg:mesh.local.heap_free_bytes{service:meshtastic-firmware})",
|
||||||
|
"aggregator": "avg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "scalar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditional_formats": [
|
||||||
|
{ "comparator": "<", "value": -100, "palette": "white_on_red" },
|
||||||
|
{ "comparator": "<", "value": 0, "palette": "white_on_yellow" },
|
||||||
|
{ "comparator": ">=", "value": 0, "palette": "white_on_green" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Total heap (bytes)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "total_heap",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.local.heap_total_bytes{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Battery level (%)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "battery",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.device.battery_level{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": { "min": "0", "max": "105" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Air utilization (TX %)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "airutil",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.device.air_util_tx{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Channel utilization (%)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "chutil",
|
||||||
|
"data_source": "metrics",
|
||||||
|
"query": "avg:mesh.device.channel_utilization{service:meshtastic-firmware} by {port}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "line"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Log volume by level",
|
||||||
|
"type": "timeseries",
|
||||||
|
"show_legend": true,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"response_format": "timeseries",
|
||||||
|
"display_type": "bars",
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "log_count",
|
||||||
|
"data_source": "logs",
|
||||||
|
"indexes": ["*"],
|
||||||
|
"compute": { "aggregation": "count" },
|
||||||
|
"search": { "query": "service:meshtastic-firmware" },
|
||||||
|
"group_by": [
|
||||||
|
{
|
||||||
|
"facet": "@level",
|
||||||
|
"limit": 10,
|
||||||
|
"sort": { "order": "desc", "aggregation": "count" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Recent ERROR / CRIT firmware logs",
|
||||||
|
"type": "list_stream",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"response_format": "event_list",
|
||||||
|
"query": {
|
||||||
|
"data_source": "logs_stream",
|
||||||
|
"query_string": "service:meshtastic-firmware (status:error OR @level:ERROR OR @level:CRIT)",
|
||||||
|
"indexes": [],
|
||||||
|
"sort": { "column": "timestamp", "order": "desc" }
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{ "field": "timestamp", "width": "auto" },
|
||||||
|
{ "field": "host", "width": "auto" },
|
||||||
|
{ "field": "@port", "width": "auto" },
|
||||||
|
{ "field": "@level", "width": "auto" },
|
||||||
|
{ "field": "@thread", "width": "auto" },
|
||||||
|
{ "field": "message", "width": "stretch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definition": {
|
||||||
|
"title": "Recorder marker events",
|
||||||
|
"type": "list_stream",
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"response_format": "event_list",
|
||||||
|
"query": {
|
||||||
|
"data_source": "logs_stream",
|
||||||
|
"query_string": "service:meshtastic-firmware @level:MARK",
|
||||||
|
"indexes": [],
|
||||||
|
"sort": { "column": "timestamp", "order": "desc" }
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{ "field": "timestamp", "width": "auto" },
|
||||||
|
{ "field": "host", "width": "auto" },
|
||||||
|
{ "field": "message", "width": "stretch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"template_variables": [
|
||||||
|
{
|
||||||
|
"name": "port",
|
||||||
|
"prefix": "port",
|
||||||
|
"available_values": [],
|
||||||
|
"default": "*"
|
||||||
|
},
|
||||||
|
{ "name": "host", "prefix": "host", "available_values": [], "default": "*" }
|
||||||
|
],
|
||||||
|
"layout_type": "ordered",
|
||||||
|
"notify_list": [],
|
||||||
|
"reflow_type": "auto"
|
||||||
|
}
|
||||||
Executable
+389
@@ -0,0 +1,389 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Forward selected recorder JSONL streams to Datadog.
|
||||||
|
|
||||||
|
Reads `.mtlog/logs.jsonl` and `.mtlog/telemetry.jsonl`, ships logs to the
|
||||||
|
Logs Intake API and telemetry numerics to the Metrics v2 series API.
|
||||||
|
Resumes from `.mtlog/.dd-cursor.json` so a daemon restart doesn't
|
||||||
|
duplicate rows already shipped from the current live files.
|
||||||
|
|
||||||
|
This forwarder does not currently backfill rotated `.jsonl.gz` archives.
|
||||||
|
If the recorder rotates before this process drains the live file, or the
|
||||||
|
forwarder is down across a rotation, those older rows are skipped.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
DD_API_KEY=... ./scripts/mtlog_to_datadog.py --tail
|
||||||
|
./scripts/mtlog_to_datadog.py --once # catch up + exit
|
||||||
|
./scripts/mtlog_to_datadog.py --since 3600 # backfill last hour from start
|
||||||
|
|
||||||
|
Default `DD_SITE` is `us5.datadoghq.com` — the team's Datadog instance.
|
||||||
|
Override via `DD_SITE=...` env var or `--site` flag for one-offs.
|
||||||
|
|
||||||
|
The forwarder is a separate process by design — a Datadog outage or
|
||||||
|
auth failure must not backpressure the recorder. We exit non-zero on
|
||||||
|
fatal config errors (missing API key) and keep retrying on transient
|
||||||
|
network/HTTP errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"requests is required. Install it in the mcp-server venv: "
|
||||||
|
"uv pip install requests",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_LOG_DIR = Path(__file__).resolve().parents[1] / ".mtlog"
|
||||||
|
_LOG_INTAKE_TPL = "https://http-intake.logs.{site}/api/v2/logs"
|
||||||
|
_METRICS_TPL = "https://api.{site}/api/v2/series"
|
||||||
|
_LOG_BATCH = 50
|
||||||
|
_METRICS_BATCH = 100
|
||||||
|
_MAX_RETRIES = 5
|
||||||
|
_RETRY_BASE_S = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
# --- streaming JSONL with byte-position cursor -------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamReader:
|
||||||
|
"""Reads a single rotating JSONL with cursor-based resume.
|
||||||
|
|
||||||
|
This tails only the live `.jsonl` file. The recorder rotates files
|
||||||
|
(live `.jsonl` → `.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`), which means
|
||||||
|
the live file shrinks abruptly. We detect that via inode change OR live
|
||||||
|
size < cursor position, and reset the live-file cursor to 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, cursor: dict[str, Any]):
|
||||||
|
self.path = path
|
||||||
|
self.cursor = cursor
|
||||||
|
|
||||||
|
def _state(self) -> tuple[int, int]:
|
||||||
|
"""Return (inode, size) for the live file. (0, 0) if missing."""
|
||||||
|
try:
|
||||||
|
st = self.path.stat()
|
||||||
|
return (st.st_ino, st.st_size)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
def iter_new_records(self) -> Iterator[dict[str, Any]]:
|
||||||
|
ino, size = self._state()
|
||||||
|
last_ino = self.cursor.get("ino")
|
||||||
|
last_pos = int(self.cursor.get("pos") or 0)
|
||||||
|
if ino == 0:
|
||||||
|
return
|
||||||
|
if last_ino is not None and last_ino != ino:
|
||||||
|
# Rotation happened. Start over.
|
||||||
|
last_pos = 0
|
||||||
|
if last_pos > size:
|
||||||
|
# Live file truncated/shrunk under us — recorder rotated.
|
||||||
|
last_pos = 0
|
||||||
|
try:
|
||||||
|
with self.path.open("r", encoding="utf-8") as fh:
|
||||||
|
fh.seek(last_pos)
|
||||||
|
for line in fh:
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
yield json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
last_pos = fh.tell()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
self.cursor["ino"] = ino
|
||||||
|
self.cursor["pos"] = last_pos
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cursor(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cursor(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
tmp = path.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(data, separators=(",", ":")))
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Datadog clients ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _DDSession:
|
||||||
|
"""Pool one HTTPS session, share retry logic."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, site: str, hostname: str) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
self.site = site
|
||||||
|
self.hostname = hostname
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"DD-API-KEY": api_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _post(self, url: str, payload: Any) -> bool:
|
||||||
|
for attempt in range(_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
resp = self.session.post(url, json=payload, timeout=30)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_wait_retry(attempt, f"network error: {e}")
|
||||||
|
continue
|
||||||
|
if 200 <= resp.status_code < 300:
|
||||||
|
return True
|
||||||
|
if resp.status_code in (408, 429, 500, 502, 503, 504):
|
||||||
|
_wait_retry(
|
||||||
|
attempt,
|
||||||
|
f"HTTP {resp.status_code} retrying",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
print(
|
||||||
|
f"datadog refused: {resp.status_code} {resp.text[:200]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_logs(self, records: list[dict[str, Any]]) -> int:
|
||||||
|
if not records:
|
||||||
|
return 0
|
||||||
|
url = _LOG_INTAKE_TPL.format(site=self.site)
|
||||||
|
sent = 0
|
||||||
|
for i in range(0, len(records), _LOG_BATCH):
|
||||||
|
batch = records[i : i + _LOG_BATCH]
|
||||||
|
if self._post(url, batch):
|
||||||
|
sent += len(batch)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
def send_metrics(self, series: list[dict[str, Any]]) -> int:
|
||||||
|
if not series:
|
||||||
|
return 0
|
||||||
|
url = _METRICS_TPL.format(site=self.site)
|
||||||
|
sent = 0
|
||||||
|
for i in range(0, len(series), _METRICS_BATCH):
|
||||||
|
batch = series[i : i + _METRICS_BATCH]
|
||||||
|
if self._post(url, {"series": batch}):
|
||||||
|
sent += len(batch)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_retry(attempt: int, reason: str) -> None:
|
||||||
|
wait = _RETRY_BASE_S * (2**attempt)
|
||||||
|
print(
|
||||||
|
f" retry {attempt + 1}/{_MAX_RETRIES} in {wait:.1f}s ({reason})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
|
||||||
|
# --- record → datadog payload ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _log_record_to_dd(rec: dict[str, Any], host: str) -> dict[str, Any]:
|
||||||
|
line = rec.get("line") or ""
|
||||||
|
tags = [
|
||||||
|
f"role:{rec.get('role')}",
|
||||||
|
f"port:{rec.get('port')}",
|
||||||
|
]
|
||||||
|
level = rec.get("level")
|
||||||
|
if level:
|
||||||
|
tags.append(f"level:{level}")
|
||||||
|
tag = rec.get("tag")
|
||||||
|
if tag:
|
||||||
|
tags.append(f"thread:{tag}")
|
||||||
|
return {
|
||||||
|
"ddsource": "meshtastic-firmware",
|
||||||
|
"service": "meshtastic-firmware",
|
||||||
|
"hostname": host,
|
||||||
|
"message": line,
|
||||||
|
"ddtags": ",".join(t for t in tags if t and "None" not in t),
|
||||||
|
"timestamp": int((rec.get("ts") or time.time()) * 1000),
|
||||||
|
"level": level,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _telemetry_record_to_metrics(
|
||||||
|
rec: dict[str, Any], host: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
fields = rec.get("fields") or {}
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
return []
|
||||||
|
variant = rec.get("variant") or "unknown"
|
||||||
|
ts = int(rec.get("ts") or time.time())
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
tags = []
|
||||||
|
if rec.get("port"):
|
||||||
|
tags.append(f"port:{rec['port']}")
|
||||||
|
if rec.get("role"):
|
||||||
|
tags.append(f"role:{rec['role']}")
|
||||||
|
if rec.get("from_node"):
|
||||||
|
tags.append(f"from_node:{rec['from_node']}")
|
||||||
|
tags.append(f"variant:{variant}")
|
||||||
|
for field, value in fields.items():
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
|
continue
|
||||||
|
metric = f"mesh.{variant}.{_metric_safe(field)}"
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"metric": metric,
|
||||||
|
"type": 3, # GAUGE
|
||||||
|
"points": [{"timestamp": ts, "value": float(value)}],
|
||||||
|
"tags": tags,
|
||||||
|
"resources": [{"type": "host", "name": host}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_safe(name: str) -> str:
|
||||||
|
# Lowercase, replace non-alnum with underscore for safe metric names.
|
||||||
|
return "".join(c.lower() if c.isalnum() else "_" for c in name)
|
||||||
|
|
||||||
|
|
||||||
|
# --- main loop ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
log_dir: Path,
|
||||||
|
*,
|
||||||
|
once: bool,
|
||||||
|
since_seconds: float | None,
|
||||||
|
poll_interval: float,
|
||||||
|
dd: _DDSession,
|
||||||
|
) -> int:
|
||||||
|
cursor_path = log_dir / ".dd-cursor.json"
|
||||||
|
cursors = _load_cursor(cursor_path)
|
||||||
|
|
||||||
|
# `--since` overrides cursor: rewind to (now-since) timestamp.
|
||||||
|
# We can't seek by timestamp directly (cursor is byte position), so
|
||||||
|
# we just reset cursors to 0 and let the time filter in iter_new
|
||||||
|
# drop older records.
|
||||||
|
cutoff_ts: float | None = None
|
||||||
|
if since_seconds is not None:
|
||||||
|
cursors = {}
|
||||||
|
cutoff_ts = time.time() - since_seconds
|
||||||
|
|
||||||
|
sent_total = {"logs": 0, "telemetry": 0}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# logs.jsonl → DD logs
|
||||||
|
log_cursor = cursors.setdefault("logs", {})
|
||||||
|
log_batch: list[dict[str, Any]] = []
|
||||||
|
for rec in _StreamReader(log_dir / "logs.jsonl", log_cursor).iter_new_records():
|
||||||
|
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
|
||||||
|
continue
|
||||||
|
log_batch.append(_log_record_to_dd(rec, dd.hostname))
|
||||||
|
if log_batch:
|
||||||
|
n = dd.send_logs(log_batch)
|
||||||
|
sent_total["logs"] += n
|
||||||
|
print(f"logs: sent {n}/{len(log_batch)}")
|
||||||
|
|
||||||
|
# telemetry.jsonl → DD metrics
|
||||||
|
telem_cursor = cursors.setdefault("telemetry", {})
|
||||||
|
metric_series: list[dict[str, Any]] = []
|
||||||
|
for rec in _StreamReader(
|
||||||
|
log_dir / "telemetry.jsonl", telem_cursor
|
||||||
|
).iter_new_records():
|
||||||
|
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
|
||||||
|
continue
|
||||||
|
metric_series.extend(_telemetry_record_to_metrics(rec, dd.hostname))
|
||||||
|
if metric_series:
|
||||||
|
n = dd.send_metrics(metric_series)
|
||||||
|
sent_total["telemetry"] += n
|
||||||
|
print(f"telemetry: sent {n}/{len(metric_series)} metric points")
|
||||||
|
|
||||||
|
_save_cursor(cursor_path, cursors)
|
||||||
|
|
||||||
|
if once:
|
||||||
|
print(f"done. logs={sent_total['logs']} metrics={sent_total['telemetry']}")
|
||||||
|
return 0
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-dir",
|
||||||
|
default=str(_DEFAULT_LOG_DIR),
|
||||||
|
help="Path to .mtlog/ (default: mcp-server/.mtlog)",
|
||||||
|
)
|
||||||
|
mode = parser.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument("--once", action="store_true", help="Catch up then exit")
|
||||||
|
mode.add_argument(
|
||||||
|
"--tail",
|
||||||
|
action="store_true",
|
||||||
|
help="Daemon: poll forever (default)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--since",
|
||||||
|
type=float,
|
||||||
|
default=None,
|
||||||
|
help="Backfill last N seconds. Resets cursor.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--poll-interval",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="Seconds between tail polls (default 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--site",
|
||||||
|
default=os.environ.get("DD_SITE", "us5.datadoghq.com"),
|
||||||
|
help=(
|
||||||
|
"Datadog site. Default is the team's instance (us5.datadoghq.com). "
|
||||||
|
"Override via DD_SITE env var or this flag."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default=socket.gethostname(),
|
||||||
|
help="Hostname tag (default: socket.gethostname())",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
api_key = os.environ.get("DD_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("DD_API_KEY env var required.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
log_dir = Path(args.log_dir)
|
||||||
|
if not log_dir.exists():
|
||||||
|
print(
|
||||||
|
f"log dir {log_dir} does not exist — start the mcp-server first.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
dd = _DDSession(api_key=api_key, site=args.site, hostname=args.host)
|
||||||
|
once = args.once and not args.tail
|
||||||
|
return run(
|
||||||
|
log_dir,
|
||||||
|
once=once,
|
||||||
|
since_seconds=args.since,
|
||||||
|
poll_interval=args.poll_interval,
|
||||||
|
dd=dd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -108,18 +108,33 @@ def build(
|
|||||||
env: str,
|
env: str,
|
||||||
with_manifest: bool = True,
|
with_manifest: bool = True,
|
||||||
userprefs_overrides: dict[str, Any] | None = None,
|
userprefs_overrides: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run `pio run -e <env>` and return artifact paths.
|
"""Run `pio run -e <env>` and return artifact paths.
|
||||||
|
|
||||||
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
|
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
|
||||||
into userPrefs.jsonc for this build only. File is restored byte-for-byte
|
into userPrefs.jsonc for this build only. File is restored byte-for-byte
|
||||||
on exit. Use `userprefs_set()` for persistent changes.
|
on exit. Use `userprefs_set()` for persistent changes.
|
||||||
|
|
||||||
|
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros to set for
|
||||||
|
this build only via `PLATFORMIO_BUILD_FLAGS`. Common useful flag:
|
||||||
|
`{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]`
|
||||||
|
prefix on every log line. Combines with the recorder so heap shows
|
||||||
|
up at log cadence (much higher resolution than the ~60 s LocalStats
|
||||||
|
packet) — see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
|
||||||
|
expand to bare `-D<NAME>` (presence-only flags).
|
||||||
"""
|
"""
|
||||||
args = ["run", "-e", env]
|
args = ["run", "-e", env]
|
||||||
if with_manifest:
|
if with_manifest:
|
||||||
args.extend(["-t", "mtjson"])
|
args.extend(["-t", "mtjson"])
|
||||||
|
extra_env = _build_flags_env(build_flags) if build_flags else None
|
||||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||||
result = pio.run(args, timeout=pio.TIMEOUT_BUILD, check=False)
|
result = pio.run(
|
||||||
|
args,
|
||||||
|
timeout=pio.TIMEOUT_BUILD,
|
||||||
|
check=False,
|
||||||
|
extra_env=extra_env,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"exit_code": result.returncode,
|
"exit_code": result.returncode,
|
||||||
"artifacts": [str(p) for p in _artifacts_for(env)],
|
"artifacts": [str(p) for p in _artifacts_for(env)],
|
||||||
@@ -127,9 +142,27 @@ def build(
|
|||||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||||
"duration_s": round(result.duration_s, 2),
|
"duration_s": round(result.duration_s, 2),
|
||||||
"userprefs": _userprefs_summary(effective),
|
"userprefs": _userprefs_summary(effective),
|
||||||
|
"build_flags": dict(build_flags) if build_flags else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_flags_env(build_flags: dict[str, Any]) -> dict[str, str]:
|
||||||
|
"""Translate `{"DEBUG_HEAP": 1, "FOO": "bar"}` → `{"PLATFORMIO_BUILD_FLAGS":
|
||||||
|
"-DDEBUG_HEAP=1 -DFOO=bar"}`. Bool True → bare `-D<NAME>`; False/None drop
|
||||||
|
the flag entirely. Other types stringify."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for key, value in build_flags.items():
|
||||||
|
if value is False or value is None:
|
||||||
|
continue
|
||||||
|
if value is True:
|
||||||
|
parts.append(f"-D{key}")
|
||||||
|
else:
|
||||||
|
parts.append(f"-D{key}={value}")
|
||||||
|
if not parts:
|
||||||
|
return {}
|
||||||
|
return {"PLATFORMIO_BUILD_FLAGS": " ".join(parts)}
|
||||||
|
|
||||||
|
|
||||||
def clean(env: str) -> dict[str, Any]:
|
def clean(env: str) -> dict[str, Any]:
|
||||||
"""Run `pio run -e <env> -t clean`."""
|
"""Run `pio run -e <env> -t clean`."""
|
||||||
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
|
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
|
||||||
@@ -146,20 +179,29 @@ def flash(
|
|||||||
port: str,
|
port: str,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
userprefs_overrides: dict[str, Any] | None = None,
|
userprefs_overrides: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
||||||
|
|
||||||
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
||||||
that pio performs will pick up the injected values.
|
that pio performs will pick up the injected values.
|
||||||
|
|
||||||
|
`build_flags` (optional): same shape as `build()` — `PLATFORMIO_BUILD_FLAGS`
|
||||||
|
is exported for the rebuild-before-upload, so the uploaded firmware
|
||||||
|
actually carries the flags. Without this propagation, `pio run -t upload`
|
||||||
|
would relink without the env var and silently drop them. Common use:
|
||||||
|
`build_flags={"DEBUG_HEAP": 1}` for the leak-hunt path.
|
||||||
"""
|
"""
|
||||||
_require_confirm(confirm, "flash")
|
_require_confirm(confirm, "flash")
|
||||||
_reject_native_env(env, "flash")
|
_reject_native_env(env, "flash")
|
||||||
connection.reject_if_tcp(port, "flash")
|
connection.reject_if_tcp(port, "flash")
|
||||||
|
extra_env = _build_flags_env(build_flags) if build_flags else None
|
||||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||||
result = pio.run(
|
result = pio.run(
|
||||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||||
timeout=pio.TIMEOUT_UPLOAD,
|
timeout=pio.TIMEOUT_UPLOAD,
|
||||||
check=False,
|
check=False,
|
||||||
|
extra_env=extra_env,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"exit_code": result.returncode,
|
"exit_code": result.returncode,
|
||||||
@@ -167,6 +209,7 @@ def flash(
|
|||||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||||
"duration_s": round(result.duration_s, 2),
|
"duration_s": round(result.duration_s, 2),
|
||||||
"userprefs": _userprefs_summary(effective),
|
"userprefs": _userprefs_summary(effective),
|
||||||
|
"build_flags": dict(build_flags) if build_flags else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""Read-side queries over the recorder's JSONL streams.
|
||||||
|
|
||||||
|
Pure functions over `mcp-server/.mtlog/`. Streaming JSONL reader: never
|
||||||
|
loads a whole file. Time-bound queries short-circuit as soon as `ts`
|
||||||
|
exceeds the requested end. The recorder writes monotonically, so a
|
||||||
|
forward scan is cheap; we don't need an index.
|
||||||
|
|
||||||
|
All time arguments accept:
|
||||||
|
- epoch seconds (int/float)
|
||||||
|
- relative strings: "-15m", "-2h", "-3d", "now"
|
||||||
|
- ISO-ish absolute strings: "2026-05-07T14:30:00" (naive timestamps are
|
||||||
|
treated as UTC)
|
||||||
|
|
||||||
|
Tools that return data ALWAYS cap their output (max_lines / max_points
|
||||||
|
/ max), and report whether more matched than was returned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from .recorder.recorder import get_recorder
|
||||||
|
|
||||||
|
_REL_RE = re.compile(r"^\s*-\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$")
|
||||||
|
_REGEX_PREVIEW_MAX = 100
|
||||||
|
_REGEX_PREVIEW_TRUNCATE = 97
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: Any, *, now: float | None = None) -> float:
|
||||||
|
"""Coerce to epoch seconds. Defaults `now` to `time.time()`."""
|
||||||
|
if value is None:
|
||||||
|
return time.time()
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"invalid time: {value!r}")
|
||||||
|
s = value.strip().lower()
|
||||||
|
if s in ("", "now"):
|
||||||
|
return time.time() if now is None else now
|
||||||
|
m = _REL_RE.match(s)
|
||||||
|
if m:
|
||||||
|
n = float(m.group(1))
|
||||||
|
unit = m.group(2)
|
||||||
|
secs = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
||||||
|
base = time.time() if now is None else now
|
||||||
|
return base - secs
|
||||||
|
# Try ISO 8601. Accept naive (assume UTC) and Z-suffixed.
|
||||||
|
try:
|
||||||
|
if s.endswith("z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.timestamp()
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"unparseable time: {value!r}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_jsonl(path: Path, *, since: float, until: float) -> Iterator[dict[str, Any]]:
|
||||||
|
"""Stream records in chronological order: rotated archives first
|
||||||
|
(oldest → newest by lex sort, which is chronological for our
|
||||||
|
`YYYYMMDD-HHMMSS-uuuuuu-NNNNN` archive naming), then the live file
|
||||||
|
last. The "keep last N" pop-front logic in the window queries
|
||||||
|
relies on records arriving in time order across files.
|
||||||
|
"""
|
||||||
|
files: list[Path] = []
|
||||||
|
# Gzipped archives are named "<stem>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz".
|
||||||
|
for archive in sorted(path.parent.glob(f"{path.stem}.*.jsonl.gz")):
|
||||||
|
files.append(archive)
|
||||||
|
if path.exists():
|
||||||
|
files.append(path)
|
||||||
|
for f in files:
|
||||||
|
opener = gzip.open if f.suffix == ".gz" else open
|
||||||
|
try:
|
||||||
|
with opener(f, "rt", encoding="utf-8") as fh: # type: ignore[arg-type]
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
ts = rec.get("ts")
|
||||||
|
if not isinstance(ts, (int, float)):
|
||||||
|
continue
|
||||||
|
if ts < since:
|
||||||
|
continue
|
||||||
|
if ts > until:
|
||||||
|
# Records are append-monotonic within a file, so
|
||||||
|
# the rest of this file is also past `until`.
|
||||||
|
# Archives can still overlap each other, so only
|
||||||
|
# short-circuit this file, not the whole scan.
|
||||||
|
break
|
||||||
|
yield rec
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
# -- queries ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def logs_window(
|
||||||
|
start: Any = "-15m",
|
||||||
|
end: Any = "now",
|
||||||
|
*,
|
||||||
|
grep: str | None = None,
|
||||||
|
level: str | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
port: str | None = None,
|
||||||
|
max_lines: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recent firmware log lines, filtered.
|
||||||
|
|
||||||
|
`level` accepts a single level name or pipe-separated set
|
||||||
|
("WARN|ERROR|CRIT"). `grep` is a regex (Python re) over the raw
|
||||||
|
`line` field. Returns the last `max_lines` matches.
|
||||||
|
"""
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
levels = _split_set(level)
|
||||||
|
if grep:
|
||||||
|
try:
|
||||||
|
grep_re = re.compile(grep)
|
||||||
|
except re.error as exc:
|
||||||
|
preview = (
|
||||||
|
grep
|
||||||
|
if len(grep) <= _REGEX_PREVIEW_MAX
|
||||||
|
else f"{grep[:_REGEX_PREVIEW_TRUNCATE]}..."
|
||||||
|
)
|
||||||
|
raise ValueError(f"invalid grep regex {preview!r}: {exc}") from exc
|
||||||
|
else:
|
||||||
|
grep_re = None
|
||||||
|
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
matched = 0
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for rec in _iter_jsonl(base / "logs.jsonl", since=s, until=e):
|
||||||
|
if levels and rec.get("level") not in levels:
|
||||||
|
continue
|
||||||
|
if tag and rec.get("tag") != tag:
|
||||||
|
continue
|
||||||
|
if port and rec.get("port") != port:
|
||||||
|
continue
|
||||||
|
if grep_re and not grep_re.search(rec.get("line") or ""):
|
||||||
|
continue
|
||||||
|
matched += 1
|
||||||
|
out.append(rec)
|
||||||
|
if len(out) > max_lines:
|
||||||
|
out.pop(0) # keep the most recent N
|
||||||
|
return {
|
||||||
|
"lines": out,
|
||||||
|
"total_matched": matched,
|
||||||
|
"dropped": max(0, matched - max_lines),
|
||||||
|
"window": {"start": s, "end": e},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_timeline(
|
||||||
|
window: Any = "1h",
|
||||||
|
*,
|
||||||
|
variant: str = "local",
|
||||||
|
field: str = "free_heap",
|
||||||
|
port: str | None = None,
|
||||||
|
max_points: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Timeseries of one telemetry field, downsampled.
|
||||||
|
|
||||||
|
`field` matches both the protobuf snake_case name (`free_heap`,
|
||||||
|
`heap_free_bytes`, `battery_level`) and camelCase (`freeHeap`).
|
||||||
|
Server-side bucket-mean downsamples to ≤ `max_points`. Returns
|
||||||
|
`slope_per_min` (linear regression slope, units/min) so a leak
|
||||||
|
detector can read one number.
|
||||||
|
"""
|
||||||
|
end = time.time()
|
||||||
|
if isinstance(window, (int, float)):
|
||||||
|
# Numeric `window` is a duration in seconds — "last N seconds".
|
||||||
|
# Without this branch, `_parse_time(-N)` would treat -N as an
|
||||||
|
# absolute epoch timestamp (i.e., Jan 1 1970 minus N seconds),
|
||||||
|
# producing a wildly negative `start` and matching nothing.
|
||||||
|
start = end - float(window)
|
||||||
|
elif isinstance(window, str) and not window.startswith("-"):
|
||||||
|
# Bare string like "1h" is sugar for "-1h".
|
||||||
|
start = _parse_time(f"-{window}", now=end)
|
||||||
|
else:
|
||||||
|
start = _parse_time(window, now=end)
|
||||||
|
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
raw: list[tuple[float, float]] = []
|
||||||
|
field_aliases = _field_aliases(field)
|
||||||
|
for rec in _iter_jsonl(base / "telemetry.jsonl", since=start, until=end):
|
||||||
|
if rec.get("variant") != variant:
|
||||||
|
continue
|
||||||
|
if port and rec.get("port") != port:
|
||||||
|
continue
|
||||||
|
fields = rec.get("fields") or {}
|
||||||
|
value: Any = None
|
||||||
|
for alias in field_aliases:
|
||||||
|
if alias in fields:
|
||||||
|
value = fields[alias]
|
||||||
|
break
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
continue
|
||||||
|
raw.append((float(rec["ts"]), float(value)))
|
||||||
|
|
||||||
|
if not raw:
|
||||||
|
return {
|
||||||
|
"points": [],
|
||||||
|
"samples": 0,
|
||||||
|
"min": None,
|
||||||
|
"max": None,
|
||||||
|
"slope_per_min": None,
|
||||||
|
"window": {"start": start, "end": end, "variant": variant, "field": field},
|
||||||
|
}
|
||||||
|
|
||||||
|
points = _downsample(raw, max_points=max_points)
|
||||||
|
values = [v for _, v in raw]
|
||||||
|
return {
|
||||||
|
"points": [{"ts": ts, "value": v} for ts, v in points],
|
||||||
|
"samples": len(raw),
|
||||||
|
"min": min(values),
|
||||||
|
"max": max(values),
|
||||||
|
"slope_per_min": _slope_per_min(raw),
|
||||||
|
"window": {"start": start, "end": end, "variant": variant, "field": field},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def packets_window(
|
||||||
|
start: Any = "-5m",
|
||||||
|
end: Any = "now",
|
||||||
|
*,
|
||||||
|
portnum: str | None = None,
|
||||||
|
from_node: str | None = None,
|
||||||
|
to_node: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
portnums = _split_set(portnum)
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
matched = 0
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for rec in _iter_jsonl(base / "packets.jsonl", since=s, until=e):
|
||||||
|
if portnums and rec.get("portnum") not in portnums:
|
||||||
|
continue
|
||||||
|
if from_node and str(rec.get("from_node")) != str(from_node):
|
||||||
|
continue
|
||||||
|
if to_node and str(rec.get("to_node")) != str(to_node):
|
||||||
|
continue
|
||||||
|
matched += 1
|
||||||
|
out.append(rec)
|
||||||
|
if len(out) > max:
|
||||||
|
out.pop(0)
|
||||||
|
return {
|
||||||
|
"packets": out,
|
||||||
|
"total_matched": matched,
|
||||||
|
"dropped": matched - max if matched > max else 0,
|
||||||
|
"window": {"start": s, "end": e},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def events_window(
|
||||||
|
start: Any = "-1h",
|
||||||
|
end: Any = "now",
|
||||||
|
*,
|
||||||
|
kind: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
kinds = _split_set(kind)
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
matched = 0
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for rec in _iter_jsonl(base / "events.jsonl", since=s, until=e):
|
||||||
|
if kinds and rec.get("kind") not in kinds:
|
||||||
|
continue
|
||||||
|
matched += 1
|
||||||
|
out.append(rec)
|
||||||
|
if len(out) > max:
|
||||||
|
out.pop(0)
|
||||||
|
return {
|
||||||
|
"events": out,
|
||||||
|
"total_matched": matched,
|
||||||
|
"dropped": matched - max if matched > max else 0,
|
||||||
|
"window": {"start": s, "end": e},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def export(
|
||||||
|
start: Any,
|
||||||
|
end: Any,
|
||||||
|
dest_dir: str,
|
||||||
|
*,
|
||||||
|
streams: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Bundle a slice of each requested stream into `dest_dir`.
|
||||||
|
|
||||||
|
For a notebook, a bug report, or a Datadog backfill. Output files
|
||||||
|
are uncompressed JSONL (callers gzip themselves if they want to).
|
||||||
|
"""
|
||||||
|
s = _parse_time(start)
|
||||||
|
e = _parse_time(end)
|
||||||
|
selected = streams or ["logs", "telemetry", "packets", "events"]
|
||||||
|
dest = Path(dest_dir)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
base = get_recorder().base_dir
|
||||||
|
paths: dict[str, str] = {}
|
||||||
|
for stream in selected:
|
||||||
|
src = base / f"{stream}.jsonl"
|
||||||
|
if not src.exists() and not list(base.glob(f"{stream}.*.jsonl.gz")):
|
||||||
|
continue
|
||||||
|
out_path = dest / f"{stream}.jsonl"
|
||||||
|
n = 0
|
||||||
|
with out_path.open("w", encoding="utf-8") as fh:
|
||||||
|
for rec in _iter_jsonl(src, since=s, until=e):
|
||||||
|
fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
|
||||||
|
n += 1
|
||||||
|
paths[stream] = str(out_path)
|
||||||
|
paths[f"{stream}_count"] = str(n)
|
||||||
|
return {"dest_dir": str(dest), "paths": paths, "window": {"start": s, "end": e}}
|
||||||
|
|
||||||
|
|
||||||
|
# -- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _split_set(value: str | None) -> set[str] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return {v.strip() for v in value.split("|") if v.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _field_aliases(field: str) -> list[str]:
|
||||||
|
"""Accept snake_case OR camelCase, plus a few legacy aliases."""
|
||||||
|
snake = field
|
||||||
|
camel = _snake_to_camel(field)
|
||||||
|
aliases = {snake, camel}
|
||||||
|
# Old protobuf fields (pre-LocalStats) used different names
|
||||||
|
legacy = {
|
||||||
|
"free_heap": ["free_heap", "freeHeap", "heap_free_bytes", "heapFreeBytes"],
|
||||||
|
"heap_free_bytes": [
|
||||||
|
"heap_free_bytes",
|
||||||
|
"heapFreeBytes",
|
||||||
|
"free_heap",
|
||||||
|
"freeHeap",
|
||||||
|
],
|
||||||
|
"total_heap": ["total_heap", "totalHeap", "heap_total_bytes", "heapTotalBytes"],
|
||||||
|
"heap_total_bytes": [
|
||||||
|
"heap_total_bytes",
|
||||||
|
"heapTotalBytes",
|
||||||
|
"total_heap",
|
||||||
|
"totalHeap",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if field in legacy:
|
||||||
|
aliases.update(legacy[field])
|
||||||
|
return list(aliases)
|
||||||
|
|
||||||
|
|
||||||
|
def _snake_to_camel(name: str) -> str:
|
||||||
|
parts = name.split("_")
|
||||||
|
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _downsample(
|
||||||
|
points: list[tuple[float, float]], *, max_points: int
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
if len(points) <= max_points:
|
||||||
|
return points
|
||||||
|
# Even-bucket mean. Preserves shape better than nth-sample picking.
|
||||||
|
n = len(points)
|
||||||
|
bucket = n / max_points
|
||||||
|
out: list[tuple[float, float]] = []
|
||||||
|
i = 0
|
||||||
|
for k in range(max_points):
|
||||||
|
end = int((k + 1) * bucket)
|
||||||
|
end = min(end, n)
|
||||||
|
if end <= i:
|
||||||
|
continue
|
||||||
|
chunk = points[i:end]
|
||||||
|
ts = chunk[len(chunk) // 2][0]
|
||||||
|
val = statistics.fmean(v for _, v in chunk)
|
||||||
|
out.append((ts, val))
|
||||||
|
i = end
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _slope_per_min(points: list[tuple[float, float]]) -> float | None:
|
||||||
|
"""Least-squares slope (units per minute). None if too few points."""
|
||||||
|
if len(points) < 2:
|
||||||
|
return None
|
||||||
|
xs = [t for t, _ in points]
|
||||||
|
ys = [v for _, v in points]
|
||||||
|
n = len(xs)
|
||||||
|
mean_x = sum(xs) / n
|
||||||
|
mean_y = sum(ys) / n
|
||||||
|
num = sum((xs[i] - mean_x) * (ys[i] - mean_y) for i in range(n))
|
||||||
|
den = sum((x - mean_x) ** 2 for x in xs)
|
||||||
|
if den == 0:
|
||||||
|
return None
|
||||||
|
slope_per_sec = num / den
|
||||||
|
return slope_per_sec * 60.0
|
||||||
@@ -92,6 +92,7 @@ def _run_capturing(
|
|||||||
cwd: Path | None = None,
|
cwd: Path | None = None,
|
||||||
timeout: float | None = None,
|
timeout: float | None = None,
|
||||||
tee_header: str | None = None,
|
tee_header: str | None = None,
|
||||||
|
extra_env: dict[str, str] | None = None,
|
||||||
) -> tuple[int, str, str, float]:
|
) -> tuple[int, str, str, float]:
|
||||||
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
|
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
|
||||||
|
|
||||||
@@ -99,6 +100,9 @@ def _run_capturing(
|
|||||||
`subprocess.TimeoutExpired` on timeout (callers map this to their own
|
`subprocess.TimeoutExpired` on timeout (callers map this to their own
|
||||||
domain-specific error).
|
domain-specific error).
|
||||||
|
|
||||||
|
`extra_env` merges into the subprocess environment (parent env stays
|
||||||
|
intact). Used for `PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar.
|
||||||
|
|
||||||
Fast path: `subprocess.run(capture_output=True)` when no flash log is
|
Fast path: `subprocess.run(capture_output=True)` when no flash log is
|
||||||
configured (unchanged behavior).
|
configured (unchanged behavior).
|
||||||
|
|
||||||
@@ -110,6 +114,9 @@ def _run_capturing(
|
|||||||
"""
|
"""
|
||||||
log_path = _flash_log_path()
|
log_path = _flash_log_path()
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
env = None
|
||||||
|
if extra_env:
|
||||||
|
env = {**os.environ, **extra_env}
|
||||||
|
|
||||||
if log_path is None:
|
if log_path is None:
|
||||||
# Fast path — unchanged.
|
# Fast path — unchanged.
|
||||||
@@ -119,6 +126,7 @@ def _run_capturing(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
proc.returncode,
|
proc.returncode,
|
||||||
@@ -145,6 +153,7 @@ def _run_capturing(
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1, # line-buffered
|
bufsize=1, # line-buffered
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
stdout_chunks: list[str] = []
|
stdout_chunks: list[str] = []
|
||||||
stderr_chunks: list[str] = []
|
stderr_chunks: list[str] = []
|
||||||
@@ -232,12 +241,17 @@ def run(
|
|||||||
cwd: Path | None = None,
|
cwd: Path | None = None,
|
||||||
timeout: float | None = TIMEOUT_DEFAULT,
|
timeout: float | None = TIMEOUT_DEFAULT,
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
|
extra_env: dict[str, str] | None = None,
|
||||||
) -> PioResult:
|
) -> PioResult:
|
||||||
"""Invoke `pio <args>` and return captured output.
|
"""Invoke `pio <args>` and return captured output.
|
||||||
|
|
||||||
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
||||||
non-zero exit; set `check=False` to inspect `returncode` manually.
|
non-zero exit; set `check=False` to inspect `returncode` manually.
|
||||||
|
|
||||||
|
`extra_env` merges into the subprocess environment — used for
|
||||||
|
`PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time
|
||||||
|
toggles that can't be expressed as command-line args.
|
||||||
|
|
||||||
If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file
|
If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file
|
||||||
line-by-line as it arrives (for live flash progress in the TUI).
|
line-by-line as it arrives (for live flash progress in the TUI).
|
||||||
"""
|
"""
|
||||||
@@ -250,6 +264,7 @@ def run(
|
|||||||
cwd=work_dir,
|
cwd=work_dir,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
tee_header=f"pio {' '.join(args)}",
|
tee_header=f"pio {' '.join(args)}",
|
||||||
|
extra_env=extra_env,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
|
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Persistent device-log capture.
|
||||||
|
|
||||||
|
Singleton `Recorder` subscribes once to the meshtastic pubsub fan-out
|
||||||
|
(`meshtastic.log.line`, `meshtastic.receive.*`, `meshtastic.connection.*`)
|
||||||
|
and appends to four JSONL files under `mcp-server/.mtlog/`. Pubsub is
|
||||||
|
process-global so a single subscription captures every active interface
|
||||||
|
(serial / TCP / BLE) without any per-connection bookkeeping.
|
||||||
|
|
||||||
|
The recorder is opt-in-by-import: importing this package is a no-op; call
|
||||||
|
`get_recorder().start()` (which `server.py` does at FastMCP app init) to
|
||||||
|
begin writing. `pause()` / `resume()` exist for the rare case the user
|
||||||
|
wants a clean stretch of file (e.g. capturing a known-good baseline).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .recorder import Recorder, get_recorder
|
||||||
|
|
||||||
|
__all__ = ["Recorder", "get_recorder"]
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""Best-effort parsers for log lines and telemetry packets.
|
||||||
|
|
||||||
|
Two flavors of log line cross our pubsub subscription:
|
||||||
|
1. Text-mode path (debug_log_api disabled): the meshtastic Python lib
|
||||||
|
accumulates bytes between protobuf frames and emits the full
|
||||||
|
firmware-formatted line, e.g.
|
||||||
|
"INFO | 12:34:56 12345 [Main] Booting"
|
||||||
|
— level, HH:MM:SS, uptime seconds, thread bracket, then message.
|
||||||
|
2. LogRecord protobuf path (debug_log_api enabled): the lib calls
|
||||||
|
`_handleLogLine(record.message)` with ONLY the message body. The
|
||||||
|
level/source/time fields on the LogRecord are dropped before
|
||||||
|
pubsub fan-out. We get e.g. just "Booting".
|
||||||
|
|
||||||
|
Both arrive on `meshtastic.log.line`. The parser tries to recover a
|
||||||
|
level + thread when the prefix is present and falls back to level=None
|
||||||
|
otherwise. Consumers who want level filtering on protobuf-mode hosts
|
||||||
|
should grep the raw `line` field instead.
|
||||||
|
|
||||||
|
Telemetry: `meshtastic.receive.telemetry` packets carry one of several
|
||||||
|
metric variants in `packet["decoded"]["telemetry"]`. We flatten the
|
||||||
|
chosen variant into a {field: value} dict so callers don't have to
|
||||||
|
know the protobuf shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Match: LEVEL | HH:MM:SS UPTIME [Thread] message
|
||||||
|
# HH:MM:SS may be ??:??:?? when RTC isn't valid. The level alternation
|
||||||
|
# below is the canonical list — DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
|
||||||
|
# macros must stay in sync with these strings.
|
||||||
|
_LINE_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
^
|
||||||
|
(?P<level>DEBUG|INFO\ |WARN\ |ERROR|CRIT\ |TRACE|HEAP\ )
|
||||||
|
\s*\|\s*
|
||||||
|
(?P<clock>(?:\d{2}:\d{2}:\d{2})|(?:\?{2}:\?{2}:\?{2}))
|
||||||
|
\s+
|
||||||
|
(?P<uptime>\d+)
|
||||||
|
\s+
|
||||||
|
(?:\[(?P<thread>[^\]]+)\]\s+)?
|
||||||
|
(?P<msg>.*)
|
||||||
|
$
|
||||||
|
""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# DEBUG_HEAP build prepends `[heap N] ` to every message body, AFTER the
|
||||||
|
# thread bracket. See src/RedirectablePrint.cpp:175.
|
||||||
|
_HEAP_PREFIX_RE = re.compile(r"^\[heap\s+(?P<heap>\d+)\]\s+(?P<rest>.*)$")
|
||||||
|
|
||||||
|
# OSThread leak/free detection. See src/concurrency/OSThread.cpp:89-91.
|
||||||
|
# Format: "------ Thread NAME leaked heap A -> B (delta) ------"
|
||||||
|
# "++++++ Thread NAME freed heap A -> B (delta) ++++++"
|
||||||
|
_THREAD_HEAP_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
^[\-+]+\s*
|
||||||
|
Thread\s+(?P<thread>\S+)\s+
|
||||||
|
(?P<kind>leaked|freed)\s+heap\s+
|
||||||
|
(?P<before>-?\d+)\s*->\s*(?P<after>-?\d+)\s+
|
||||||
|
\((?P<delta>-?\d+)\)
|
||||||
|
""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Power.cpp:908 periodic heap status (DEBUG_HEAP only).
|
||||||
|
# Format: "Heap status: FREE/TOTAL bytes free (DELTA), running R/N threads"
|
||||||
|
_HEAP_STATUS_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
Heap\s+status:\s+
|
||||||
|
(?P<free>\d+)\s*/\s*(?P<total>\d+)\s+bytes\s+free
|
||||||
|
(?:\s+\((?P<delta>-?\d+)\))?
|
||||||
|
""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
|
_HEAP_BRACKET_RE = re.compile(r"^heap\s+(?P<heap>\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log_line(line: str) -> dict[str, Any]:
|
||||||
|
"""Best-effort decompose a raw firmware log line.
|
||||||
|
|
||||||
|
Returns a dict with at least `line` (the original, unmodified — ANSI
|
||||||
|
codes preserved for fidelity). Adds `level`, `tag`, `clock`,
|
||||||
|
`uptime_s`, and `msg` when the full prefix is present.
|
||||||
|
|
||||||
|
Handles two firmware quirks:
|
||||||
|
- LogRecord.message can carry ANSI color escapes from RedirectablePrint
|
||||||
|
(the BLE/StreamAPI path inherited the colored body in some builds).
|
||||||
|
We strip ANSI before regex matching so the prefix survives.
|
||||||
|
- DEBUG_HEAP injects `[heap N]` after the thread bracket. When NO
|
||||||
|
thread name is set, the heap takes the thread bracket position —
|
||||||
|
looks like `[heap 12345] msg`. We detect that shape and move it
|
||||||
|
out of `tag` and into `heap_free`.
|
||||||
|
|
||||||
|
DEBUG_HEAP-build extras (when `[heap N]` is injected): `heap_free`
|
||||||
|
(bytes), and when a `Thread X leaked|freed heap` line is recognized,
|
||||||
|
`heap_event` = {kind, thread, before, after, delta}.
|
||||||
|
|
||||||
|
Never raises.
|
||||||
|
"""
|
||||||
|
out: dict[str, Any] = {"line": line}
|
||||||
|
if not line:
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Strip ANSI escapes BEFORE any regex matching. The original `line`
|
||||||
|
# stays in `out["line"]` for fidelity / future grep.
|
||||||
|
clean = _ANSI_RE.sub("", line)
|
||||||
|
|
||||||
|
m = _LINE_RE.match(clean)
|
||||||
|
msg: str | None = None
|
||||||
|
if m:
|
||||||
|
level = m.group("level").rstrip()
|
||||||
|
out["level"] = level
|
||||||
|
out["clock"] = m.group("clock")
|
||||||
|
try:
|
||||||
|
out["uptime_s"] = int(m.group("uptime"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
out["uptime_s"] = None
|
||||||
|
thread = m.group("thread")
|
||||||
|
if thread:
|
||||||
|
# If "thread" is actually the heap prefix taking the bracket
|
||||||
|
# position (DEBUG_HEAP build, no thread set), capture heap
|
||||||
|
# and leave tag unset.
|
||||||
|
hb = _HEAP_BRACKET_RE.match(thread.strip())
|
||||||
|
if hb:
|
||||||
|
try:
|
||||||
|
out["heap_free"] = int(hb.group("heap"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
out["tag"] = thread
|
||||||
|
msg = m.group("msg")
|
||||||
|
out["msg"] = msg
|
||||||
|
else:
|
||||||
|
# No prefix — bare LogRecord.message body. Inspect the whole
|
||||||
|
# line for DEBUG_HEAP-style content; the heap-prefix and
|
||||||
|
# thread-leak patterns can survive on either path.
|
||||||
|
msg = clean
|
||||||
|
|
||||||
|
# DEBUG_HEAP per-line heap prefix: `[heap 92344] message`.
|
||||||
|
# Sits AFTER the thread bracket and BEFORE the message body, but
|
||||||
|
# for bare LogRecord lines it's at the start. Match it at the
|
||||||
|
# head of `msg`.
|
||||||
|
if msg:
|
||||||
|
hp = _HEAP_PREFIX_RE.match(msg)
|
||||||
|
if hp:
|
||||||
|
try:
|
||||||
|
out["heap_free"] = int(hp.group("heap"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Strip the prefix from `msg` so a grep on the message
|
||||||
|
# body doesn't have to know about it.
|
||||||
|
out["msg"] = hp.group("rest")
|
||||||
|
msg = hp.group("rest")
|
||||||
|
|
||||||
|
# Thread-level leak/free detection.
|
||||||
|
thr = _THREAD_HEAP_RE.search(msg)
|
||||||
|
if thr:
|
||||||
|
try:
|
||||||
|
out["heap_event"] = {
|
||||||
|
"kind": thr.group("kind"),
|
||||||
|
"thread": thr.group("thread"),
|
||||||
|
"before": int(thr.group("before")),
|
||||||
|
"after": int(thr.group("after")),
|
||||||
|
"delta": int(thr.group("delta")),
|
||||||
|
}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Power.cpp periodic "Heap status: F/T bytes free (D), running ..."
|
||||||
|
hs = _HEAP_STATUS_RE.search(msg)
|
||||||
|
if hs:
|
||||||
|
try:
|
||||||
|
out["heap_free"] = int(hs.group("free"))
|
||||||
|
out["heap_total"] = int(hs.group("total"))
|
||||||
|
if hs.group("delta") is not None:
|
||||||
|
out["heap_delta"] = int(hs.group("delta"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# -- Telemetry ----------------------------------------------------------
|
||||||
|
|
||||||
|
# Order matters: meshtastic-python decoded packets use the protobuf
|
||||||
|
# `oneof variant` field name (snake_case) as the dict key.
|
||||||
|
_TELEMETRY_VARIANTS = (
|
||||||
|
("device_metrics", "device"),
|
||||||
|
("local_stats", "local"),
|
||||||
|
("environment_metrics", "environment"),
|
||||||
|
("power_metrics", "power"),
|
||||||
|
("air_quality_metrics", "airQuality"),
|
||||||
|
("health_metrics", "health"),
|
||||||
|
("host_metrics", "host"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_telemetry(packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Pull the telemetry variant + flat fields out of a `meshtastic.receive.telemetry`
|
||||||
|
packet. Returns None when the shape isn't what we expect — so the
|
||||||
|
caller can fall back to a generic packets.jsonl row.
|
||||||
|
"""
|
||||||
|
if not isinstance(packet, dict):
|
||||||
|
return None
|
||||||
|
decoded = packet.get("decoded")
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
return None
|
||||||
|
telem = decoded.get("telemetry")
|
||||||
|
if not isinstance(telem, dict):
|
||||||
|
return None
|
||||||
|
# The Python lib produces dict-of-camelCase keys via MessageToDict.
|
||||||
|
# Try both camelCase and snake_case to be robust to lib version drift.
|
||||||
|
for snake, label in _TELEMETRY_VARIANTS:
|
||||||
|
camel = _snake_to_camel(snake)
|
||||||
|
for key in (snake, camel):
|
||||||
|
value = telem.get(key)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
"variant": label,
|
||||||
|
"fields": {k: _scalarize(v) for k, v in value.items()},
|
||||||
|
"time": telem.get("time"),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snake_to_camel(name: str) -> str:
|
||||||
|
parts = name.split("_")
|
||||||
|
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _scalarize(value: Any) -> Any:
|
||||||
|
"""Keep telemetry fields JSON-friendly. Lists/dicts pass through
|
||||||
|
untouched; bytes -> hex string; protobuf enums occasionally arrive
|
||||||
|
as ints (fine) or strings (also fine)."""
|
||||||
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||||
|
return bytes(value).hex()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# -- Generic packet summary ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_packet(
|
||||||
|
packet: dict[str, Any], *, payload_hex_len: int = 64
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reduce a packet dict to a stable, queryable summary. Drops the
|
||||||
|
full payload bytes — the recorder records summaries, not pcaps.
|
||||||
|
"""
|
||||||
|
if not isinstance(packet, dict):
|
||||||
|
return {"raw_type": type(packet).__name__}
|
||||||
|
decoded = packet.get("decoded") if isinstance(packet.get("decoded"), dict) else {}
|
||||||
|
portnum = decoded.get("portnum") if isinstance(decoded, dict) else None
|
||||||
|
payload = decoded.get("payload") if isinstance(decoded, dict) else None
|
||||||
|
payload_hex = None
|
||||||
|
payload_size = None
|
||||||
|
if isinstance(payload, (bytes, bytearray, memoryview)):
|
||||||
|
b = bytes(payload)
|
||||||
|
payload_size = len(b)
|
||||||
|
payload_hex = b[:payload_hex_len].hex() if b else ""
|
||||||
|
elif isinstance(payload, str):
|
||||||
|
# Some decoded payloads (text messages) come as decoded strings.
|
||||||
|
payload_size = len(payload)
|
||||||
|
payload_hex = None # not bytes
|
||||||
|
return {
|
||||||
|
"from_node": packet.get("fromId") or packet.get("from"),
|
||||||
|
"to_node": packet.get("toId") or packet.get("to"),
|
||||||
|
"portnum": portnum,
|
||||||
|
"hop_limit": packet.get("hopLimit"),
|
||||||
|
"want_ack": packet.get("wantAck"),
|
||||||
|
"rx_rssi": packet.get("rxRssi"),
|
||||||
|
"rx_snr": packet.get("rxSnr"),
|
||||||
|
"channel": packet.get("channel"),
|
||||||
|
"id": packet.get("id"),
|
||||||
|
"payload_size": payload_size,
|
||||||
|
"payload_hex_prefix": payload_hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Interface identification ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def interface_label(interface: Any) -> dict[str, Any]:
|
||||||
|
"""Stable identifier for the meshtastic interface that emitted an event.
|
||||||
|
|
||||||
|
Used as the `port`/`role` tag on every recorded row. SerialInterface
|
||||||
|
has `devPath`; TCPInterface has `hostname`+`portNumber`; BLEInterface
|
||||||
|
has `address`. Falls back to the class name when none of those exist.
|
||||||
|
"""
|
||||||
|
if interface is None:
|
||||||
|
return {"port": None, "role": None}
|
||||||
|
dev_path = getattr(interface, "devPath", None)
|
||||||
|
if dev_path:
|
||||||
|
return {"port": str(dev_path), "role": "serial"}
|
||||||
|
hostname = getattr(interface, "hostname", None)
|
||||||
|
if hostname:
|
||||||
|
port_num = getattr(interface, "portNumber", None)
|
||||||
|
endpoint = f"tcp://{hostname}:{port_num}" if port_num else f"tcp://{hostname}"
|
||||||
|
return {"port": endpoint, "role": "tcp"}
|
||||||
|
address = getattr(interface, "address", None)
|
||||||
|
if address:
|
||||||
|
return {"port": str(address), "role": "ble"}
|
||||||
|
return {"port": type(interface).__name__, "role": None}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""Process-global recorder singleton.
|
||||||
|
|
||||||
|
Subscribes once to the meshtastic pubsub fan-out and writes four append-only
|
||||||
|
JSONL streams under `mcp-server/.mtlog/`. The pubsub fan-out is
|
||||||
|
process-global — a single subscription captures every active interface
|
||||||
|
without per-connection bookkeeping.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
logs.jsonl — every `meshtastic.log.line` event (best-effort prefix
|
||||||
|
parsed for level/tag/uptime; raw `line` always preserved)
|
||||||
|
telemetry.jsonl — `meshtastic.receive.telemetry` packets, flattened by
|
||||||
|
variant (device / local / environment / power / etc.)
|
||||||
|
packets.jsonl — every other `meshtastic.receive.*` packet, summarized
|
||||||
|
(portnum, hops, RSSI/SNR, payload size + 64-byte hex)
|
||||||
|
events.jsonl — connection lifecycle, node-DB updates, and manual
|
||||||
|
`mark_event` rows. Lower volume; useful for aligning
|
||||||
|
timelines.
|
||||||
|
|
||||||
|
Pause/resume: `pause()` flips a flag; subscriptions stay registered. The
|
||||||
|
write methods short-circuit when paused, so we don't lose ordering when
|
||||||
|
resumed (we just have a gap). No queueing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import parsers
|
||||||
|
from .rotating import _RotatingJsonl
|
||||||
|
|
||||||
|
_DEFAULT_DIR = Path(__file__).resolve().parents[3] / ".mtlog"
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
"""Singleton write-side of the persistent log capture system."""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: Path | None = None) -> None:
|
||||||
|
self.base_dir = Path(base_dir) if base_dir else _DEFAULT_DIR
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._started = False
|
||||||
|
self._paused = False
|
||||||
|
self._pause_reason: str | None = None
|
||||||
|
self._started_at: float | None = None
|
||||||
|
self._handlers: list[tuple[str, Any]] = []
|
||||||
|
self._files: dict[str, _RotatingJsonl] = {}
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Idempotent. Safe to call from FastMCP app startup."""
|
||||||
|
with self._lock:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._files = {
|
||||||
|
"logs": _RotatingJsonl(self.base_dir / "logs.jsonl"),
|
||||||
|
"telemetry": _RotatingJsonl(self.base_dir / "telemetry.jsonl"),
|
||||||
|
"packets": _RotatingJsonl(self.base_dir / "packets.jsonl"),
|
||||||
|
"events": _RotatingJsonl(self.base_dir / "events.jsonl"),
|
||||||
|
}
|
||||||
|
self._wire_pubsub()
|
||||||
|
self._started = True
|
||||||
|
self._started_at = time.time()
|
||||||
|
# Write the recorder_start marker after the initialization block.
|
||||||
|
# `_write_event()` re-checks recorder state via `_files_snapshot()`,
|
||||||
|
# so keeping this out of the setup block avoids nested lifecycle work.
|
||||||
|
self._write_event(kind="recorder_start", label="recorder_started")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if not self._started:
|
||||||
|
return
|
||||||
|
self._unwire_pubsub()
|
||||||
|
for f in self._files.values():
|
||||||
|
f.close()
|
||||||
|
self._files = {}
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def pause(self, reason: str | None = None) -> None:
|
||||||
|
# Write the pause marker BEFORE flipping the flag — `_write_event`
|
||||||
|
# short-circuits when paused, so the order matters for this event
|
||||||
|
# to actually land in events.jsonl.
|
||||||
|
self._write_event(
|
||||||
|
kind="recorder_pause",
|
||||||
|
label="paused",
|
||||||
|
note=reason,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._paused = True
|
||||||
|
self._pause_reason = reason
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
# Mirror of `pause()`: clear the flag first, then write the marker
|
||||||
|
# so it isn't suppressed by the still-paused short-circuit.
|
||||||
|
with self._lock:
|
||||||
|
self._paused = False
|
||||||
|
self._pause_reason = None
|
||||||
|
self._write_event(kind="recorder_resume", label="resumed")
|
||||||
|
|
||||||
|
# -- pubsub wiring ------------------------------------------------
|
||||||
|
|
||||||
|
def _wire_pubsub(self) -> None:
|
||||||
|
from pubsub import pub # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
# Subscribers — one per topic. Each pubsub publisher sends
|
||||||
|
# keyword args matching its handler's signature; pubsub
|
||||||
|
# introspects the function signature to route args.
|
||||||
|
bindings = [
|
||||||
|
("meshtastic.log.line", self._on_log_line),
|
||||||
|
("meshtastic.serial.line", self._on_serial_line),
|
||||||
|
("meshtastic.receive", self._on_receive),
|
||||||
|
("meshtastic.receive.telemetry", self._on_telemetry),
|
||||||
|
("meshtastic.connection.established", self._on_connection_established),
|
||||||
|
("meshtastic.connection.lost", self._on_connection_lost),
|
||||||
|
("meshtastic.node.updated", self._on_node_updated),
|
||||||
|
]
|
||||||
|
for topic, handler in bindings:
|
||||||
|
try:
|
||||||
|
pub.subscribe(handler, topic)
|
||||||
|
self._handlers.append((topic, handler))
|
||||||
|
except Exception as exc:
|
||||||
|
# If pubsub refuses one binding (signature mismatch on
|
||||||
|
# an old lib version), log it and keep the rest.
|
||||||
|
log.warning("Recorder failed to subscribe to %s: %s", topic, exc)
|
||||||
|
|
||||||
|
def _unwire_pubsub(self) -> None:
|
||||||
|
from pubsub import pub # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
for topic, handler in self._handlers:
|
||||||
|
try:
|
||||||
|
pub.unsubscribe(handler, topic)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._handlers.clear()
|
||||||
|
|
||||||
|
# -- handlers -----------------------------------------------------
|
||||||
|
#
|
||||||
|
# Pubsub callbacks must never raise. Every handler is wrapped in a
|
||||||
|
# try/except that swallows so a bug here can't take down the
|
||||||
|
# SerialInterface receive thread.
|
||||||
|
#
|
||||||
|
# Threading: handlers fire on whatever thread the meshtastic library
|
||||||
|
# dispatches from (varies by interface), while `stop()` clears
|
||||||
|
# `self._files` under `self._lock`. We snapshot `_files` under the
|
||||||
|
# lock at the top of each handler so a concurrent stop can't
|
||||||
|
# KeyError us mid-write. The actual file write goes through
|
||||||
|
# `_RotatingJsonl` which has its own lock.
|
||||||
|
|
||||||
|
def _files_snapshot(self) -> dict[str, _RotatingJsonl] | None:
|
||||||
|
"""Atomic-ish view of `self._files`. Returns None when the recorder
|
||||||
|
is paused or stopped, so handlers can early-exit cleanly without
|
||||||
|
racing `stop()`'s clear."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._started or self._paused:
|
||||||
|
return None
|
||||||
|
return dict(self._files)
|
||||||
|
|
||||||
|
def _on_log_line(self, line: str, interface: Any = None) -> None:
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
parsed = parsers.parse_log_line(str(line))
|
||||||
|
ts = time.time()
|
||||||
|
record: dict[str, Any] = {
|
||||||
|
"ts": ts,
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"level": parsed.get("level"),
|
||||||
|
"tag": parsed.get("tag"),
|
||||||
|
"uptime_s": parsed.get("uptime_s"),
|
||||||
|
"line": parsed["line"],
|
||||||
|
}
|
||||||
|
# DEBUG_HEAP enrichments (only present when the firmware
|
||||||
|
# was built with -DDEBUG_HEAP=1). Surface as first-class
|
||||||
|
# fields so logs_window can grep/filter on them and so
|
||||||
|
# heap_free synthesizes a telemetry point below.
|
||||||
|
if "heap_free" in parsed:
|
||||||
|
record["heap_free"] = parsed["heap_free"]
|
||||||
|
if "heap_total" in parsed:
|
||||||
|
record["heap_total"] = parsed["heap_total"]
|
||||||
|
if "heap_delta" in parsed:
|
||||||
|
record["heap_delta"] = parsed["heap_delta"]
|
||||||
|
heap_event = parsed.get("heap_event")
|
||||||
|
if heap_event:
|
||||||
|
record["heap_event"] = heap_event
|
||||||
|
files["logs"].write(record)
|
||||||
|
|
||||||
|
# If the line carried a heap snapshot, also write it as a
|
||||||
|
# synthesized LocalStats-shaped row so telemetry_timeline
|
||||||
|
# picks it up at log cadence (much higher resolution than
|
||||||
|
# the ~60 s LocalStats packet). Tagged source=debug_heap so
|
||||||
|
# consumers can filter if mixing scales is unwanted.
|
||||||
|
heap_free = parsed.get("heap_free")
|
||||||
|
if isinstance(heap_free, int):
|
||||||
|
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
|
||||||
|
heap_total = parsed.get("heap_total")
|
||||||
|
if isinstance(heap_total, int):
|
||||||
|
fields["heap_total_bytes"] = heap_total
|
||||||
|
files["telemetry"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"from_node": None,
|
||||||
|
"variant": "local",
|
||||||
|
"fields": fields,
|
||||||
|
"source": "debug_heap",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_serial_line(self, line: str, port: str | None = None) -> None:
|
||||||
|
"""Text-mode passive tap. Fired from `serial_session._drain` when a
|
||||||
|
`pio device monitor` subprocess is running.
|
||||||
|
|
||||||
|
Same parse + heap-synthesis path as `_on_log_line`, but receives
|
||||||
|
the raw text-formatted line (full level/clock/uptime/thread/`[heap N]`/
|
||||||
|
body). On DEBUG_HEAP builds in text mode this gives us per-log-line
|
||||||
|
heap data — far higher cadence than LocalStats, and works without
|
||||||
|
protobuf API mode (no SerialInterface required).
|
||||||
|
"""
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parsed = parsers.parse_log_line(str(line))
|
||||||
|
ts = time.time()
|
||||||
|
record: dict[str, Any] = {
|
||||||
|
"ts": ts,
|
||||||
|
"port": port,
|
||||||
|
"role": "serial_session",
|
||||||
|
"level": parsed.get("level"),
|
||||||
|
"tag": parsed.get("tag"),
|
||||||
|
"uptime_s": parsed.get("uptime_s"),
|
||||||
|
"line": parsed["line"],
|
||||||
|
}
|
||||||
|
if "heap_free" in parsed:
|
||||||
|
record["heap_free"] = parsed["heap_free"]
|
||||||
|
if "heap_total" in parsed:
|
||||||
|
record["heap_total"] = parsed["heap_total"]
|
||||||
|
if "heap_delta" in parsed:
|
||||||
|
record["heap_delta"] = parsed["heap_delta"]
|
||||||
|
heap_event = parsed.get("heap_event")
|
||||||
|
if heap_event:
|
||||||
|
record["heap_event"] = heap_event
|
||||||
|
files["logs"].write(record)
|
||||||
|
|
||||||
|
# Synthesize a heap_free telemetry sample whenever the line
|
||||||
|
# carries one — same logic as _on_log_line, tagged source so
|
||||||
|
# consumers can distinguish text-mode tap from protobuf path.
|
||||||
|
heap_free = parsed.get("heap_free")
|
||||||
|
if isinstance(heap_free, int):
|
||||||
|
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
|
||||||
|
heap_total = parsed.get("heap_total")
|
||||||
|
if isinstance(heap_total, int):
|
||||||
|
fields["heap_total_bytes"] = heap_total
|
||||||
|
files["telemetry"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"port": port,
|
||||||
|
"role": "serial_session",
|
||||||
|
"from_node": None,
|
||||||
|
"variant": "local",
|
||||||
|
"fields": fields,
|
||||||
|
"source": "debug_heap_serial",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_telemetry(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
extracted = parsers.extract_telemetry(packet)
|
||||||
|
if extracted is None:
|
||||||
|
# Couldn't extract a known variant — fall through to the
|
||||||
|
# generic `_on_receive` path, which will still fire for
|
||||||
|
# this packet via the parent topic.
|
||||||
|
return
|
||||||
|
record = {
|
||||||
|
"ts": time.time(),
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"from_node": packet.get("fromId") or packet.get("from"),
|
||||||
|
"variant": extracted["variant"],
|
||||||
|
"fields": extracted["fields"],
|
||||||
|
"device_time": extracted.get("time"),
|
||||||
|
}
|
||||||
|
files["telemetry"].write(record)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_receive(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||||
|
# Generic-receive fires for EVERY packet. Telemetry packets get
|
||||||
|
# recorded twice (here and in _on_telemetry) — that's intentional:
|
||||||
|
# packets.jsonl is the universal record, telemetry.jsonl is the
|
||||||
|
# structured timeseries view.
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
summary = parsers.summarize_packet(packet)
|
||||||
|
record = {
|
||||||
|
"ts": time.time(),
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
**summary,
|
||||||
|
}
|
||||||
|
files["packets"].write(record)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_connection_established(self, interface: Any = None) -> None:
|
||||||
|
self._write_event(
|
||||||
|
kind="connection_established",
|
||||||
|
interface=interface,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_connection_lost(self, interface: Any = None) -> None:
|
||||||
|
self._write_event(
|
||||||
|
kind="connection_lost",
|
||||||
|
interface=interface,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_node_updated(
|
||||||
|
self, node: dict[str, Any] | None = None, interface: Any = None
|
||||||
|
) -> None:
|
||||||
|
# Lower-volume than packets but informative — node ID, hops away,
|
||||||
|
# last heard. Skip the user dict if absent.
|
||||||
|
try:
|
||||||
|
user = (node or {}).get("user") if isinstance(node, dict) else None
|
||||||
|
self._write_event(
|
||||||
|
kind="node_updated",
|
||||||
|
interface=interface,
|
||||||
|
data={
|
||||||
|
"num": (node or {}).get("num"),
|
||||||
|
"id": (user or {}).get("id"),
|
||||||
|
"short": (user or {}).get("shortName"),
|
||||||
|
"long": (user or {}).get("longName"),
|
||||||
|
"hops_away": (node or {}).get("hopsAway"),
|
||||||
|
"snr": (node or {}).get("snr"),
|
||||||
|
"last_heard": (node or {}).get("lastHeard"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- public write helpers -----------------------------------------
|
||||||
|
|
||||||
|
def mark_event(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
note: str | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""User-facing marker. Writes to events.jsonl AND emits a
|
||||||
|
synthetic logs.jsonl row tagged level=MARK so timelines align.
|
||||||
|
"""
|
||||||
|
ts = self._write_event(kind="mark", label=label, note=note, data=data)
|
||||||
|
# Mirror into logs so a single logs_window grep finds it.
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is not None:
|
||||||
|
try:
|
||||||
|
files["logs"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"port": None,
|
||||||
|
"role": "marker",
|
||||||
|
"level": "MARK",
|
||||||
|
"tag": "mark_event",
|
||||||
|
"line": f"[mark] {label}" + (f" — {note}" if note else ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ts": ts, "label": label}
|
||||||
|
|
||||||
|
def _write_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
label: str | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
interface: Any = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> float:
|
||||||
|
ts = time.time()
|
||||||
|
# Lifecycle markers (recorder_start, recorder_pause, recorder_resume)
|
||||||
|
# arrive at choreographed moments — `pause()` writes BEFORE flipping
|
||||||
|
# the flag and `resume()` writes AFTER clearing it, so those calls
|
||||||
|
# see _paused=False here. Other event kinds short-circuit when
|
||||||
|
# paused via the snapshot guard below.
|
||||||
|
files = self._files_snapshot()
|
||||||
|
if files is None:
|
||||||
|
return ts
|
||||||
|
try:
|
||||||
|
tags = parsers.interface_label(interface)
|
||||||
|
files["events"].write(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"kind": kind,
|
||||||
|
"label": label,
|
||||||
|
"note": note,
|
||||||
|
"port": tags["port"],
|
||||||
|
"role": tags["role"],
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ts
|
||||||
|
|
||||||
|
# -- introspection ------------------------------------------------
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"running": self._started,
|
||||||
|
"paused": self._paused,
|
||||||
|
"pause_reason": self._pause_reason,
|
||||||
|
"started_at": self._started_at,
|
||||||
|
"base_dir": str(self.base_dir),
|
||||||
|
"files": {name: f.status() for name, f in self._files.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
def force_rotate_all(self) -> dict[str, Any]:
|
||||||
|
"""Test/admin hook: rotate every stream right now."""
|
||||||
|
with self._lock:
|
||||||
|
files = list(self._files.values())
|
||||||
|
for f in files:
|
||||||
|
f.force_rotate()
|
||||||
|
# `status()` re-acquires `self._lock`; release before calling it.
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
|
||||||
|
# -- module-level singleton accessor ------------------------------------
|
||||||
|
|
||||||
|
_INSTANCE_LOCK = threading.Lock()
|
||||||
|
_INSTANCE: Recorder | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_recorder() -> Recorder:
|
||||||
|
"""Return the process-global Recorder. Created on first call.
|
||||||
|
|
||||||
|
Honors `MESHTASTIC_MCP_LOG_DIR` env var for the base directory
|
||||||
|
(used by tests to redirect to a tmpdir).
|
||||||
|
"""
|
||||||
|
global _INSTANCE
|
||||||
|
with _INSTANCE_LOCK:
|
||||||
|
if _INSTANCE is None:
|
||||||
|
override = os.environ.get("MESHTASTIC_MCP_LOG_DIR")
|
||||||
|
base = Path(override) if override else None
|
||||||
|
_INSTANCE = Recorder(base_dir=base)
|
||||||
|
return _INSTANCE
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Append-only JSONL writer with size-capped rotation.
|
||||||
|
|
||||||
|
A `_RotatingJsonl` owns one live `.jsonl` file. Writes are line-delimited
|
||||||
|
JSON objects (one row per call). When the live file exceeds `max_bytes`,
|
||||||
|
it is closed, gzipped to `<name>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`,
|
||||||
|
and the live file resets to empty. Old archives past `keep_archives` are
|
||||||
|
unlinked oldest-first.
|
||||||
|
|
||||||
|
Size check is amortized — `os.fstat` runs every `check_every` writes,
|
||||||
|
not per-write, so the hot path stays at one `fh.write` + one `fh.flush`.
|
||||||
|
|
||||||
|
Threading: every public method acquires `self._lock`. The recorder runs
|
||||||
|
several pubsub handlers on whatever thread the meshtastic library
|
||||||
|
dispatches from (varies by interface), and queries from MCP tool calls
|
||||||
|
arrive on the FastMCP request thread, so this lock is not optional.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class _RotatingJsonl:
|
||||||
|
"""Append-only JSONL with size rotation. Thread-safe."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
max_bytes: int = 100 * 1024 * 1024,
|
||||||
|
keep_archives: int = 5,
|
||||||
|
check_every: int = 1000,
|
||||||
|
) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
self.keep_archives = keep_archives
|
||||||
|
self.check_every = check_every
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._fh: Any = None
|
||||||
|
self._writes_since_check = 0
|
||||||
|
self._rotations = 0
|
||||||
|
self._lines_written = 0
|
||||||
|
self._last_ts: float | None = None
|
||||||
|
self._open()
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._fh = self.path.open("a", encoding="utf-8")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._fh is not None:
|
||||||
|
try:
|
||||||
|
self._fh.close()
|
||||||
|
finally:
|
||||||
|
self._fh = None
|
||||||
|
|
||||||
|
# -- write --------------------------------------------------------
|
||||||
|
|
||||||
|
def write(self, record: dict[str, Any]) -> None:
|
||||||
|
"""Append one JSON object as a line. Triggers rotation if oversized."""
|
||||||
|
line = json.dumps(record, separators=(",", ":"), default=str) + "\n"
|
||||||
|
with self._lock:
|
||||||
|
if self._fh is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._fh.write(line)
|
||||||
|
self._fh.flush()
|
||||||
|
except Exception:
|
||||||
|
# Best-effort: a failed write must not crash the pubsub
|
||||||
|
# handler. Caller has no way to react anyway.
|
||||||
|
return
|
||||||
|
self._lines_written += 1
|
||||||
|
ts = record.get("ts")
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
self._last_ts = float(ts)
|
||||||
|
self._writes_since_check += 1
|
||||||
|
if self._writes_since_check >= self.check_every:
|
||||||
|
self._writes_since_check = 0
|
||||||
|
self._maybe_rotate()
|
||||||
|
|
||||||
|
# -- rotation -----------------------------------------------------
|
||||||
|
|
||||||
|
def _maybe_rotate(self) -> None:
|
||||||
|
# Caller holds self._lock.
|
||||||
|
try:
|
||||||
|
size = os.fstat(self._fh.fileno()).st_size
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if size < self.max_bytes:
|
||||||
|
return
|
||||||
|
self._rotate_locked()
|
||||||
|
|
||||||
|
def _rotate_locked(self) -> None:
|
||||||
|
# Close, gzip-rename, reopen empty, prune oldest archives.
|
||||||
|
try:
|
||||||
|
self._fh.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._fh = None
|
||||||
|
# Microsecond-resolution timestamp + per-instance counter so back-
|
||||||
|
# to-back rotations (small max_bytes, repeated `force_rotate()`,
|
||||||
|
# or chatty test loops) get unique archive filenames. The lex
|
||||||
|
# sort order of `YYYYMMDD-HHMMSS-uuuuuu-NNNNN` is chronological,
|
||||||
|
# which `_prune_archives()` and `log_query._iter_jsonl()` both
|
||||||
|
# rely on.
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
||||||
|
archive = self.path.with_suffix(f".{stamp}-{self._rotations:05d}.jsonl.gz")
|
||||||
|
try:
|
||||||
|
with self.path.open("rb") as src, gzip.open(archive, "wb") as dst:
|
||||||
|
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
||||||
|
self.path.unlink()
|
||||||
|
except Exception:
|
||||||
|
# Rotation is best-effort. If gzip fails, leave the file
|
||||||
|
# in place and re-open it; we'll try again next check.
|
||||||
|
pass
|
||||||
|
self._open()
|
||||||
|
self._rotations += 1
|
||||||
|
self._prune_archives()
|
||||||
|
|
||||||
|
def _prune_archives(self) -> None:
|
||||||
|
# Match siblings of self.path.name with `.jsonl.gz` suffix.
|
||||||
|
prefix = self.path.stem # "logs" for "logs.jsonl"
|
||||||
|
# Archive filenames are already lexicographically chronological.
|
||||||
|
# Prune by name, not mtime, so copied/restored files don't reorder.
|
||||||
|
archives = sorted(self.path.parent.glob(f"{prefix}.*.jsonl.gz"))
|
||||||
|
excess = len(archives) - self.keep_archives
|
||||||
|
for old in archives[: max(0, excess)]:
|
||||||
|
try:
|
||||||
|
old.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def force_rotate(self) -> None:
|
||||||
|
"""Test/admin hook: rotate immediately regardless of size."""
|
||||||
|
with self._lock:
|
||||||
|
if self._fh is not None:
|
||||||
|
self._rotate_locked()
|
||||||
|
|
||||||
|
# -- introspection ------------------------------------------------
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
size = os.fstat(self._fh.fileno()).st_size if self._fh else 0
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
return {
|
||||||
|
"path": str(self.path),
|
||||||
|
"size": size,
|
||||||
|
"lines": self._lines_written,
|
||||||
|
"last_ts": self._last_ts,
|
||||||
|
"rotations": self._rotations,
|
||||||
|
}
|
||||||
@@ -46,7 +46,23 @@ class SerialSession:
|
|||||||
|
|
||||||
|
|
||||||
def _drain(session: SerialSession) -> None:
|
def _drain(session: SerialSession) -> None:
|
||||||
"""Reader thread: line-by-line pull stdout into buffer."""
|
"""Reader thread: line-by-line pull stdout into buffer.
|
||||||
|
|
||||||
|
Each line is also published to the `meshtastic.serial.line` pubsub
|
||||||
|
topic so the persistent recorder can capture it without holding its
|
||||||
|
own port. This is the text-mode tap path: when no SerialInterface is
|
||||||
|
open, the firmware emits full formatted lines (level + clock + uptime
|
||||||
|
+ thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we
|
||||||
|
fan them out to whoever is listening. Pubsub is best-effort —
|
||||||
|
publish failures must never block the reader.
|
||||||
|
"""
|
||||||
|
# Lazy import: pubsub isn't required just to import this module
|
||||||
|
# (e.g., during static analysis), and we want a clean test surface.
|
||||||
|
try:
|
||||||
|
from pubsub import pub # type: ignore[import-untyped]
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
pub = None
|
||||||
|
|
||||||
assert session.proc.stdout is not None
|
assert session.proc.stdout is not None
|
||||||
try:
|
try:
|
||||||
for line in session.proc.stdout:
|
for line in session.proc.stdout:
|
||||||
@@ -54,6 +70,16 @@ def _drain(session: SerialSession) -> None:
|
|||||||
with session.lock:
|
with session.lock:
|
||||||
session.buffer.append(line_stripped)
|
session.buffer.append(line_stripped)
|
||||||
session.total_lines += 1
|
session.total_lines += 1
|
||||||
|
if pub is not None:
|
||||||
|
try:
|
||||||
|
pub.sendMessage(
|
||||||
|
"meshtastic.serial.line",
|
||||||
|
line=line_stripped,
|
||||||
|
port=session.port,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# A subscriber raising must not break the reader.
|
||||||
|
pass
|
||||||
except Exception: # pragma: no cover - defensive
|
except Exception: # pragma: no cover - defensive
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ etc.). Business logic does not live here.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
@@ -17,14 +18,34 @@ from . import (
|
|||||||
flash,
|
flash,
|
||||||
hw_tools,
|
hw_tools,
|
||||||
info,
|
info,
|
||||||
|
log_query,
|
||||||
registry,
|
registry,
|
||||||
serial_session,
|
serial_session,
|
||||||
)
|
)
|
||||||
from . import userprefs as userprefs_mod
|
from . import userprefs as userprefs_mod
|
||||||
|
from .recorder import get_recorder
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastMCP("meshtastic-mcp")
|
app = FastMCP("meshtastic-mcp")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_recorder() -> None:
|
||||||
|
# Persistent device-log capture. Starts on first import — pubsub fan-out
|
||||||
|
# is process-global, so subscribing here captures every active interface
|
||||||
|
# (whether opened by an MCP tool, a pytest fixture, or a serial_session).
|
||||||
|
# Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py
|
||||||
|
# for the full design. Recorder startup is best-effort: an unwritable
|
||||||
|
# log dir or pubsub mismatch should not take the MCP server down.
|
||||||
|
try:
|
||||||
|
get_recorder().start()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to start persistent recorder: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
_start_recorder()
|
||||||
|
|
||||||
|
|
||||||
# ---------- Discovery & metadata ------------------------------------------
|
# ---------- Discovery & metadata ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +96,7 @@ def build(
|
|||||||
env: str,
|
env: str,
|
||||||
with_manifest: bool = True,
|
with_manifest: bool = True,
|
||||||
userprefs: dict[str, Any] | None = None,
|
userprefs: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build firmware for one env via `pio run -e <env>`.
|
"""Build firmware for one env via `pio run -e <env>`.
|
||||||
|
|
||||||
@@ -86,8 +108,21 @@ def build(
|
|||||||
build via userPrefs.jsonc injection. The file is restored after the build
|
build via userPrefs.jsonc injection. The file is restored after the build
|
||||||
completes. Use `userprefs_manifest` to discover available keys. Use
|
completes. Use `userprefs_manifest` to discover available keys. Use
|
||||||
`userprefs_set` for persistent changes.
|
`userprefs_set` for persistent changes.
|
||||||
|
|
||||||
|
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for this build
|
||||||
|
only, injected via `PLATFORMIO_BUILD_FLAGS`. Common pattern:
|
||||||
|
`build_flags={"DEBUG_HEAP": 1}` enables per-thread leak detection + a
|
||||||
|
`[heap N]` prefix on every log line. The recorder picks the prefix up
|
||||||
|
automatically and synthesizes a high-resolution heap timeline that
|
||||||
|
`telemetry_timeline(field="free_heap")` can read alongside the normal
|
||||||
|
~60 s LocalStats packets. Pair with `/leakhunt` for classification.
|
||||||
"""
|
"""
|
||||||
return flash.build(env, with_manifest=with_manifest, userprefs_overrides=userprefs)
|
return flash.build(
|
||||||
|
env,
|
||||||
|
with_manifest=with_manifest,
|
||||||
|
userprefs_overrides=userprefs,
|
||||||
|
build_flags=build_flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
@@ -105,6 +140,7 @@ def pio_flash(
|
|||||||
port: str,
|
port: str,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
userprefs: dict[str, Any] | None = None,
|
userprefs: dict[str, Any] | None = None,
|
||||||
|
build_flags: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
|
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
|
||||||
|
|
||||||
@@ -114,8 +150,19 @@ def pio_flash(
|
|||||||
|
|
||||||
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
|
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
|
||||||
build via userPrefs.jsonc injection; restored after upload.
|
build via userPrefs.jsonc injection; restored after upload.
|
||||||
|
|
||||||
|
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for the
|
||||||
|
rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags
|
||||||
|
to actually land in the uploaded firmware — without it, the implicit
|
||||||
|
rebuild relinks without the env var and silently drops them.
|
||||||
"""
|
"""
|
||||||
return flash.flash(env, port, confirm=confirm, userprefs_overrides=userprefs)
|
return flash.flash(
|
||||||
|
env,
|
||||||
|
port,
|
||||||
|
confirm=confirm,
|
||||||
|
userprefs_overrides=userprefs,
|
||||||
|
build_flags=build_flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
@@ -734,3 +781,185 @@ def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
|
|||||||
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
||||||
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
|
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
|
||||||
return hw_tools.picotool_raw(args, confirm=confirm)
|
return hw_tools.picotool_raw(args, confirm=confirm)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Persistent device-log capture (recorder) ----------------------
|
||||||
|
#
|
||||||
|
# The recorder is autouse — it starts at server import and continuously
|
||||||
|
# writes every meshtastic pubsub event to JSONL files under .mtlog/. These
|
||||||
|
# tools are query-only over those files, plus a few lifecycle controls.
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def logs_window(
|
||||||
|
start: str = "-15m",
|
||||||
|
end: str = "now",
|
||||||
|
grep: str | None = None,
|
||||||
|
level: str | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
port: str | None = None,
|
||||||
|
max_lines: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recent firmware log lines from the persistent recorder.
|
||||||
|
|
||||||
|
Filters by time window, regex over the line, level (single or
|
||||||
|
pipe-separated set like "WARN|ERROR|CRIT"), thread-name tag, and
|
||||||
|
interface port. Returns up to max_lines most-recent matches.
|
||||||
|
|
||||||
|
Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601.
|
||||||
|
|
||||||
|
Note: lines arriving via the LogRecord protobuf path (when
|
||||||
|
set_debug_log_api(True) is on) come without level prefix — the
|
||||||
|
meshtastic Python lib drops record.level before fan-out. For those,
|
||||||
|
`level` filter won't match; use `grep` instead.
|
||||||
|
"""
|
||||||
|
return log_query.logs_window(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
grep=grep,
|
||||||
|
level=level,
|
||||||
|
tag=tag,
|
||||||
|
port=port,
|
||||||
|
max_lines=max_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def telemetry_timeline(
|
||||||
|
window: str = "1h",
|
||||||
|
variant: str = "local",
|
||||||
|
field: str = "free_heap",
|
||||||
|
port: str | None = None,
|
||||||
|
max_points: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Time series of one telemetry field, downsampled to <= max_points.
|
||||||
|
|
||||||
|
`variant` ∈ device, local, environment, power, airQuality, health, host.
|
||||||
|
`field` accepts snake_case or camelCase; common aliases (free_heap ↔
|
||||||
|
heap_free_bytes) are normalized.
|
||||||
|
|
||||||
|
Returns slope_per_min (linear-regression slope, units/minute) so a
|
||||||
|
leak detector can read one number — negative slope on free_heap over
|
||||||
|
a long window indicates a real leak.
|
||||||
|
|
||||||
|
LocalStats variant ("local") cadence is ~60 s (whatever the device's
|
||||||
|
`device_update_interval` is set to), so a 1 h window gives ~60 raw
|
||||||
|
points. Bucket-mean downsampling preserves shape.
|
||||||
|
"""
|
||||||
|
return log_query.telemetry_timeline(
|
||||||
|
window=window,
|
||||||
|
variant=variant,
|
||||||
|
field=field,
|
||||||
|
port=port,
|
||||||
|
max_points=max_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def packets_window(
|
||||||
|
start: str = "-5m",
|
||||||
|
end: str = "now",
|
||||||
|
portnum: str | None = None,
|
||||||
|
from_node: str | None = None,
|
||||||
|
to_node: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recent mesh packets recorded by the recorder.
|
||||||
|
|
||||||
|
Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload
|
||||||
|
size + first 64 bytes hex) — full payload bytes are not stored.
|
||||||
|
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
|
||||||
|
"""
|
||||||
|
return log_query.packets_window(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
portnum=portnum,
|
||||||
|
from_node=from_node,
|
||||||
|
to_node=to_node,
|
||||||
|
max=max,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def events_window(
|
||||||
|
start: str = "-1h",
|
||||||
|
end: str = "now",
|
||||||
|
kind: str | None = None,
|
||||||
|
max: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return recorder events: connection lifecycle, node updates, and `mark_event` markers.
|
||||||
|
|
||||||
|
`kind` ∈ recorder_start, recorder_pause, recorder_resume,
|
||||||
|
connection_established, connection_lost, node_updated, mark.
|
||||||
|
Pipe-separated sets ("connection_lost|connection_established") work.
|
||||||
|
"""
|
||||||
|
return log_query.events_window(start=start, end=end, kind=kind, max=max)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def mark_event(
|
||||||
|
label: str,
|
||||||
|
note: str | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Drop a named marker into events.jsonl AND logs.jsonl.
|
||||||
|
|
||||||
|
Useful for aligning a timeline around a known stimulus: call before
|
||||||
|
and after a stress workload, then query telemetry_timeline /
|
||||||
|
logs_window with the markers' timestamps as bounds.
|
||||||
|
|
||||||
|
The marker also lands in logs.jsonl with level=MARK so a single
|
||||||
|
grep over logs picks it up.
|
||||||
|
"""
|
||||||
|
return get_recorder().mark_event(label=label, note=note, data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_status() -> dict[str, Any]:
|
||||||
|
"""Return recorder runtime info: running, paused, file sizes, last_ts per stream.
|
||||||
|
|
||||||
|
Use this to sanity-check that capture is working before you trust a
|
||||||
|
`logs_window` / `telemetry_timeline` result.
|
||||||
|
"""
|
||||||
|
return get_recorder().status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Pause writes to all four streams. Pubsub subscriptions stay active —
|
||||||
|
we just drop events on the floor while paused. Resume with `recorder_resume`.
|
||||||
|
|
||||||
|
Use when capturing a known-good baseline that you don't want to
|
||||||
|
pollute with pre-test noise. Default state is recording; this is
|
||||||
|
rarely needed.
|
||||||
|
"""
|
||||||
|
get_recorder().pause(reason=reason)
|
||||||
|
return {"ok": True, "paused": True, "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_resume() -> dict[str, Any]:
|
||||||
|
"""Resume writes after `recorder_pause`. No-op if already running."""
|
||||||
|
get_recorder().resume()
|
||||||
|
return {"ok": True, "paused": False}
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def recorder_export(
|
||||||
|
start: str,
|
||||||
|
end: str,
|
||||||
|
dest_dir: str,
|
||||||
|
streams: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Bundle a slice of the recorder's streams into `dest_dir`.
|
||||||
|
|
||||||
|
Writes one uncompressed JSONL per requested stream (logs / telemetry /
|
||||||
|
packets / events). Useful for: attaching to a bug report, feeding a
|
||||||
|
notebook, or backfilling Datadog after the fact.
|
||||||
|
"""
|
||||||
|
return log_query.export(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
dest_dir=dest_dir,
|
||||||
|
streams=streams,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Unit tests for the `build_flags` injection on `flash.build()`.
|
||||||
|
|
||||||
|
We don't actually run pio here — too slow, requires hardware-aware envs.
|
||||||
|
We test the translation layer (`_build_flags_env`) and that the env vars
|
||||||
|
are threaded through pio.run correctly via mock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from meshtastic_mcp import flash, pio
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFlagsEnv:
|
||||||
|
def test_simple_value(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": 1})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"}
|
||||||
|
|
||||||
|
def test_string_value(self) -> None:
|
||||||
|
out = flash._build_flags_env({"FOO": "bar"})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DFOO=bar"}
|
||||||
|
|
||||||
|
def test_bool_true_is_bare_flag(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": True})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP"}
|
||||||
|
|
||||||
|
def test_bool_false_dropped(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": False, "OTHER": 1})
|
||||||
|
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DOTHER=1"}
|
||||||
|
|
||||||
|
def test_none_dropped(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": None})
|
||||||
|
assert out == {}
|
||||||
|
|
||||||
|
def test_multiple_combined(self) -> None:
|
||||||
|
out = flash._build_flags_env({"DEBUG_HEAP": 1, "FOO": "x", "BAR": True})
|
||||||
|
# Order isn't guaranteed in dict iteration, so check membership.
|
||||||
|
flags = out["PLATFORMIO_BUILD_FLAGS"].split()
|
||||||
|
assert set(flags) == {"-DDEBUG_HEAP=1", "-DFOO=x", "-DBAR"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPropagatesFlags:
|
||||||
|
def test_extra_env_passed_to_pio_run(self) -> None:
|
||||||
|
# Mock pio.run so we don't actually invoke pio. Capture extra_env.
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class _StubResult:
|
||||||
|
returncode = 0
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
duration_s = 0.1
|
||||||
|
|
||||||
|
def _stub(args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return _StubResult()
|
||||||
|
|
||||||
|
with patch.object(pio, "run", side_effect=_stub):
|
||||||
|
with patch.object(flash, "_artifacts_for", return_value=[]):
|
||||||
|
out = flash.build(
|
||||||
|
"fake-env",
|
||||||
|
with_manifest=False,
|
||||||
|
build_flags={"DEBUG_HEAP": 1},
|
||||||
|
)
|
||||||
|
assert captured["args"] == ["run", "-e", "fake-env"]
|
||||||
|
assert captured["kwargs"]["extra_env"] == {
|
||||||
|
"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"
|
||||||
|
}
|
||||||
|
assert out["build_flags"] == {"DEBUG_HEAP": 1}
|
||||||
|
|
||||||
|
def test_no_flags_means_no_extra_env(self) -> None:
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class _StubResult:
|
||||||
|
returncode = 0
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
duration_s = 0.1
|
||||||
|
|
||||||
|
def _stub(args, **kwargs):
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return _StubResult()
|
||||||
|
|
||||||
|
with patch.object(pio, "run", side_effect=_stub):
|
||||||
|
with patch.object(flash, "_artifacts_for", return_value=[]):
|
||||||
|
flash.build("fake-env", with_manifest=False)
|
||||||
|
assert captured["kwargs"]["extra_env"] is None
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
"""Unit tests for the persistent device-log recorder.
|
||||||
|
|
||||||
|
Hardware-free: drives the Recorder through its `_on_*` handlers with
|
||||||
|
synthetic packet/line dicts, then queries via log_query. Validates
|
||||||
|
prefix parsing, telemetry variant dispatch, marker round-trip, time
|
||||||
|
window filtering, downsampling, slope estimation, and gzip rotation
|
||||||
|
+ archive pruning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pubsub
|
||||||
|
import pytest
|
||||||
|
from meshtastic_mcp import log_query
|
||||||
|
from meshtastic_mcp.recorder.parsers import (
|
||||||
|
extract_telemetry,
|
||||||
|
interface_label,
|
||||||
|
parse_log_line,
|
||||||
|
summarize_packet,
|
||||||
|
)
|
||||||
|
from meshtastic_mcp.recorder.recorder import Recorder
|
||||||
|
from meshtastic_mcp.recorder.rotating import _RotatingJsonl
|
||||||
|
|
||||||
|
# -- isolation: every test gets a fresh Recorder + tmp dir -----------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recorder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Recorder:
|
||||||
|
# Redirect both the Recorder and the module-level singleton lookup
|
||||||
|
# to the same tmp dir so log_query queries the same files we write.
|
||||||
|
monkeypatch.setenv("MESHTASTIC_MCP_LOG_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"meshtastic_mcp.recorder.recorder._INSTANCE", None, raising=False
|
||||||
|
)
|
||||||
|
r = Recorder(base_dir=tmp_path)
|
||||||
|
r.start()
|
||||||
|
monkeypatch.setattr("meshtastic_mcp.recorder.recorder._INSTANCE", r, raising=False)
|
||||||
|
yield r
|
||||||
|
r.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeIface:
|
||||||
|
devPath = "/dev/cu.fake"
|
||||||
|
|
||||||
|
|
||||||
|
# -- parsers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseLogLine:
|
||||||
|
def test_full_prefix(self) -> None:
|
||||||
|
out = parse_log_line("INFO | 12:34:56 12345 [Main] Booting")
|
||||||
|
assert out["level"] == "INFO"
|
||||||
|
assert out["tag"] == "Main"
|
||||||
|
assert out["uptime_s"] == 12345
|
||||||
|
assert out["msg"] == "Booting"
|
||||||
|
assert out["clock"] == "12:34:56"
|
||||||
|
|
||||||
|
def test_invalid_clock(self) -> None:
|
||||||
|
out = parse_log_line("WARN | ??:??:?? 7 [SerialConsole] Boot")
|
||||||
|
assert out["level"] == "WARN"
|
||||||
|
assert out["clock"] == "??:??:??"
|
||||||
|
assert out["uptime_s"] == 7
|
||||||
|
|
||||||
|
def test_no_thread_bracket(self) -> None:
|
||||||
|
out = parse_log_line("DEBUG | 00:00:00 0 raw message body")
|
||||||
|
assert out["level"] == "DEBUG"
|
||||||
|
assert out.get("tag") is None
|
||||||
|
assert out["msg"] == "raw message body"
|
||||||
|
|
||||||
|
def test_bare_message(self) -> None:
|
||||||
|
# LogRecord.message path — no level prefix at all.
|
||||||
|
out = parse_log_line("just a bare message")
|
||||||
|
assert "level" not in out or out.get("level") is None
|
||||||
|
assert out["line"] == "just a bare message"
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
assert parse_log_line("") == {"line": ""}
|
||||||
|
|
||||||
|
def test_debug_heap_prefix_extracted(self) -> None:
|
||||||
|
out = parse_log_line("INFO | 12:34:56 12345 [Main] [heap 92344] Booting")
|
||||||
|
assert out["level"] == "INFO"
|
||||||
|
assert out["tag"] == "Main"
|
||||||
|
assert out["heap_free"] == 92344
|
||||||
|
assert out["msg"] == "Booting"
|
||||||
|
|
||||||
|
def test_debug_heap_prefix_on_bare_line(self) -> None:
|
||||||
|
# LogRecord.message path: no level prefix but still has [heap N].
|
||||||
|
out = parse_log_line("[heap 12345] some message")
|
||||||
|
assert out["heap_free"] == 12345
|
||||||
|
assert out["msg"] == "some message"
|
||||||
|
|
||||||
|
def test_thread_leak_event(self) -> None:
|
||||||
|
out = parse_log_line(
|
||||||
|
"HEAP | 00:00:01 100 [Power] [heap 90000] "
|
||||||
|
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------"
|
||||||
|
)
|
||||||
|
assert out["level"] == "HEAP"
|
||||||
|
assert out["heap_free"] == 90000
|
||||||
|
ev = out["heap_event"]
|
||||||
|
assert ev["kind"] == "leaked"
|
||||||
|
assert ev["thread"] == "MeshPacket"
|
||||||
|
assert ev["before"] == 92344
|
||||||
|
assert ev["after"] == 90000
|
||||||
|
assert ev["delta"] == -2344
|
||||||
|
|
||||||
|
def test_thread_freed_event(self) -> None:
|
||||||
|
out = parse_log_line(
|
||||||
|
"++++++ Thread Router freed heap 1000 -> 1500 (500) ++++++"
|
||||||
|
)
|
||||||
|
ev = out["heap_event"]
|
||||||
|
assert ev["kind"] == "freed"
|
||||||
|
assert ev["thread"] == "Router"
|
||||||
|
assert ev["delta"] == 500
|
||||||
|
|
||||||
|
def test_heap_status_periodic(self) -> None:
|
||||||
|
out = parse_log_line(
|
||||||
|
"HEAP | 00:00:30 30 [Power] "
|
||||||
|
"Heap status: 92344/200000 bytes free (-128), running 8/12 threads"
|
||||||
|
)
|
||||||
|
assert out["heap_free"] == 92344
|
||||||
|
assert out["heap_total"] == 200000
|
||||||
|
assert out["heap_delta"] == -128
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorderDebugHeapSynthesis:
|
||||||
|
def test_log_with_heap_writes_telemetry(self, recorder: "Recorder") -> None:
|
||||||
|
# When a log line carries [heap N], the recorder should also
|
||||||
|
# emit a synthesized telemetry row tagged source=debug_heap.
|
||||||
|
recorder._on_log_line(
|
||||||
|
"INFO | 00:00:00 1 [Main] [heap 88888] hello",
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
|
||||||
|
assert len(synth) == 1
|
||||||
|
assert synth[0]["fields"]["heap_free_bytes"] == 88888
|
||||||
|
assert synth[0]["variant"] == "local"
|
||||||
|
|
||||||
|
def test_heap_status_writes_total_too(self, recorder: "Recorder") -> None:
|
||||||
|
recorder._on_log_line(
|
||||||
|
"HEAP | 00:00:30 30 [Power] "
|
||||||
|
"Heap status: 50000/200000 bytes free (-100), running 8/12 threads",
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
|
||||||
|
assert synth[-1]["fields"]["heap_free_bytes"] == 50000
|
||||||
|
assert synth[-1]["fields"]["heap_total_bytes"] == 200000
|
||||||
|
|
||||||
|
def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None:
|
||||||
|
# Plain log line (no [heap N], no Heap status) — telemetry.jsonl
|
||||||
|
# should NOT gain a synth row.
|
||||||
|
before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface())
|
||||||
|
after = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||||
|
assert after == before
|
||||||
|
|
||||||
|
def test_thread_leak_event_persists_on_log_row(self, recorder: "Recorder") -> None:
|
||||||
|
recorder._on_log_line(
|
||||||
|
"HEAP | 00:00:01 100 [Power] [heap 90000] "
|
||||||
|
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------",
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
json.loads(r)
|
||||||
|
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
if r
|
||||||
|
]
|
||||||
|
evt_rows = [r for r in rows if r.get("heap_event")]
|
||||||
|
assert len(evt_rows) == 1
|
||||||
|
assert evt_rows[0]["heap_event"]["thread"] == "MeshPacket"
|
||||||
|
assert evt_rows[0]["heap_event"]["delta"] == -2344
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerialTap:
|
||||||
|
def test_serial_line_records_log_and_synthesizes_heap(
|
||||||
|
self, recorder: "Recorder"
|
||||||
|
) -> None:
|
||||||
|
recorder._on_serial_line(
|
||||||
|
"INFO | 00:00:00 5 [Main] [heap 88888] tap-line",
|
||||||
|
port="/dev/cu.tap",
|
||||||
|
)
|
||||||
|
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
log_rows = [json.loads(r) for r in logs if r]
|
||||||
|
# Find the row from this call (port=/dev/cu.tap, role=serial_session)
|
||||||
|
tap_rows = [r for r in log_rows if r.get("port") == "/dev/cu.tap"]
|
||||||
|
assert len(tap_rows) == 1
|
||||||
|
assert tap_rows[0]["role"] == "serial_session"
|
||||||
|
assert tap_rows[0]["level"] == "INFO"
|
||||||
|
assert tap_rows[0]["tag"] == "Main"
|
||||||
|
assert tap_rows[0]["heap_free"] == 88888
|
||||||
|
synth = [json.loads(r) for r in telem if '"source":"debug_heap_serial"' in r]
|
||||||
|
assert len(synth) == 1
|
||||||
|
assert synth[0]["fields"]["heap_free_bytes"] == 88888
|
||||||
|
assert synth[0]["role"] == "serial_session"
|
||||||
|
|
||||||
|
def test_serial_line_thread_leak_event(self, recorder: "Recorder") -> None:
|
||||||
|
recorder._on_serial_line(
|
||||||
|
"HEAP | 00:00:30 30 [Power] [heap 53484] "
|
||||||
|
"------ Thread Router leaked heap 53612 -> 53484 (-128) ------",
|
||||||
|
port="/dev/cu.tap",
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
json.loads(r)
|
||||||
|
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
if r
|
||||||
|
]
|
||||||
|
evt = [r for r in rows if r.get("heap_event")]
|
||||||
|
assert len(evt) == 1
|
||||||
|
assert evt[0]["heap_event"]["thread"] == "Router"
|
||||||
|
assert evt[0]["heap_event"]["delta"] == -128
|
||||||
|
# Heap also synthesized.
|
||||||
|
telem = (recorder.base_dir / "telemetry.jsonl").read_text()
|
||||||
|
assert '"source":"debug_heap_serial"' in telem
|
||||||
|
|
||||||
|
def test_serial_line_pause(self, recorder: "Recorder") -> None:
|
||||||
|
recorder.pause("baseline")
|
||||||
|
recorder._on_serial_line(
|
||||||
|
"INFO | 00:00:00 1 [t] [heap 1000] dropped",
|
||||||
|
port="/dev/cu.tap",
|
||||||
|
)
|
||||||
|
# Only the pause event row should exist; no tap row.
|
||||||
|
logs = (recorder.base_dir / "logs.jsonl").read_text()
|
||||||
|
assert "dropped" not in logs
|
||||||
|
|
||||||
|
def test_serial_line_handler_swallows_exceptions(
|
||||||
|
self, recorder: "Recorder"
|
||||||
|
) -> None:
|
||||||
|
# Hostile input — should not raise.
|
||||||
|
recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type]
|
||||||
|
recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type]
|
||||||
|
# Survived.
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractTelemetry:
|
||||||
|
def test_local_stats_camel(self) -> None:
|
||||||
|
pkt = {
|
||||||
|
"decoded": {
|
||||||
|
"telemetry": {
|
||||||
|
"localStats": {"heap_total_bytes": 1000, "heap_free_bytes": 600}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = extract_telemetry(pkt)
|
||||||
|
assert out is not None
|
||||||
|
assert out["variant"] == "local"
|
||||||
|
assert out["fields"]["heap_free_bytes"] == 600
|
||||||
|
|
||||||
|
def test_device_metrics_snake(self) -> None:
|
||||||
|
pkt = {
|
||||||
|
"decoded": {
|
||||||
|
"telemetry": {"device_metrics": {"battery_level": 88, "voltage": 4.1}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = extract_telemetry(pkt)
|
||||||
|
assert out is not None
|
||||||
|
assert out["variant"] == "device"
|
||||||
|
assert out["fields"]["battery_level"] == 88
|
||||||
|
|
||||||
|
def test_unknown_variant_returns_none(self) -> None:
|
||||||
|
assert extract_telemetry({"decoded": {"telemetry": {"weird": {}}}}) is None
|
||||||
|
assert extract_telemetry({}) is None
|
||||||
|
assert extract_telemetry({"decoded": "not-a-dict"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarizePacket:
|
||||||
|
def test_text_with_payload(self) -> None:
|
||||||
|
pkt = {
|
||||||
|
"fromId": "!abc",
|
||||||
|
"toId": "!def",
|
||||||
|
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
|
||||||
|
"hopLimit": 3,
|
||||||
|
}
|
||||||
|
out = summarize_packet(pkt)
|
||||||
|
assert out["from_node"] == "!abc"
|
||||||
|
assert out["portnum"] == "TEXT_MESSAGE_APP"
|
||||||
|
assert out["payload_size"] == 5
|
||||||
|
assert out["payload_hex_prefix"] == "68656c6c6f"
|
||||||
|
|
||||||
|
def test_no_decoded(self) -> None:
|
||||||
|
out = summarize_packet({"fromId": "!abc"})
|
||||||
|
assert out["from_node"] == "!abc"
|
||||||
|
assert out["portnum"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestInterfaceLabel:
|
||||||
|
def test_serial(self) -> None:
|
||||||
|
assert interface_label(_FakeIface()) == {
|
||||||
|
"port": "/dev/cu.fake",
|
||||||
|
"role": "serial",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_tcp(self) -> None:
|
||||||
|
class T:
|
||||||
|
hostname = "node.lan"
|
||||||
|
portNumber = 4403
|
||||||
|
|
||||||
|
assert interface_label(T()) == {"port": "tcp://node.lan:4403", "role": "tcp"}
|
||||||
|
|
||||||
|
def test_unknown(self) -> None:
|
||||||
|
assert interface_label(object()) == {"port": "object", "role": None}
|
||||||
|
|
||||||
|
def test_none(self) -> None:
|
||||||
|
assert interface_label(None) == {"port": None, "role": None}
|
||||||
|
|
||||||
|
|
||||||
|
# -- recorder write side ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorderWrites:
|
||||||
|
def test_log_line_is_recorded(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface())
|
||||||
|
path = recorder.base_dir / "logs.jsonl"
|
||||||
|
rows = [json.loads(line) for line in path.read_text().splitlines() if line]
|
||||||
|
# First row is recorder_start_event mirror? No — that's events.jsonl only.
|
||||||
|
assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows)
|
||||||
|
|
||||||
|
def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None:
|
||||||
|
# _on_telemetry alone — only telemetry.jsonl
|
||||||
|
recorder._on_telemetry(
|
||||||
|
{
|
||||||
|
"fromId": "!abc",
|
||||||
|
"decoded": {"telemetry": {"localStats": {"heap_free_bytes": 600}}},
|
||||||
|
},
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
telem_rows = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||||
|
assert any('"variant":"local"' in r for r in telem_rows)
|
||||||
|
|
||||||
|
def test_packets_summary(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_receive(
|
||||||
|
{
|
||||||
|
"fromId": "!abc",
|
||||||
|
"toId": "!def",
|
||||||
|
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi"},
|
||||||
|
},
|
||||||
|
_FakeIface(),
|
||||||
|
)
|
||||||
|
rows = (recorder.base_dir / "packets.jsonl").read_text().splitlines()
|
||||||
|
assert any('"portnum":"TEXT_MESSAGE_APP"' in r for r in rows)
|
||||||
|
|
||||||
|
def test_mark_event_round_trip(self, recorder: Recorder) -> None:
|
||||||
|
out = recorder.mark_event("checkpoint", note="midpoint")
|
||||||
|
assert "ts" in out
|
||||||
|
events = (recorder.base_dir / "events.jsonl").read_text().splitlines()
|
||||||
|
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||||
|
assert any('"label":"checkpoint"' in r and '"kind":"mark"' in r for r in events)
|
||||||
|
assert any('"level":"MARK"' in r and "checkpoint" in r for r in logs)
|
||||||
|
|
||||||
|
def test_pause_drops_writes(self, recorder: Recorder) -> None:
|
||||||
|
before = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
|
||||||
|
recorder.pause(reason="baseline")
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 1 [t] swallowed", _FakeIface())
|
||||||
|
after = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
|
||||||
|
assert after == before
|
||||||
|
recorder.resume()
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 2 [t] kept", _FakeIface())
|
||||||
|
post_resume = (recorder.base_dir / "logs.jsonl").read_text()
|
||||||
|
assert "kept" in post_resume
|
||||||
|
|
||||||
|
def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None:
|
||||||
|
# If the writer dies, the pubsub callback must NOT raise — that
|
||||||
|
# would crash the meshtastic receive thread.
|
||||||
|
bad_packet = object() # not a dict
|
||||||
|
recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||||
|
recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||||
|
recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type]
|
||||||
|
# No assertion needed — survival is the test.
|
||||||
|
|
||||||
|
|
||||||
|
# -- log_query read side ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogQuery:
|
||||||
|
def test_logs_window_grep_and_level(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
|
||||||
|
recorder._on_log_line("WARN | 12:00:01 2 [B] bravo failed", _FakeIface())
|
||||||
|
recorder._on_log_line("ERROR | 12:00:02 3 [C] charlie failed", _FakeIface())
|
||||||
|
|
||||||
|
out = log_query.logs_window(start="-1m", level="WARN|ERROR", max_lines=10)
|
||||||
|
assert out["total_matched"] == 2
|
||||||
|
levels = {r["level"] for r in out["lines"]}
|
||||||
|
assert levels == {"WARN", "ERROR"}
|
||||||
|
|
||||||
|
out2 = log_query.logs_window(start="-1m", grep=r"failed$", max_lines=10)
|
||||||
|
assert out2["total_matched"] == 2
|
||||||
|
|
||||||
|
def test_logs_window_invalid_regex(self, recorder: Recorder) -> None:
|
||||||
|
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
|
||||||
|
with pytest.raises(ValueError, match="invalid grep regex"):
|
||||||
|
log_query.logs_window(start="-1m", grep="(")
|
||||||
|
|
||||||
|
def test_telemetry_timeline_slope_and_downsample(self, recorder: Recorder) -> None:
|
||||||
|
# Synthesize a downward leak: 100 points, free_heap drops 1 byte/sample.
|
||||||
|
base_ts = time.time() - 60
|
||||||
|
for i in range(100):
|
||||||
|
recorder._files["telemetry"].write(
|
||||||
|
{
|
||||||
|
"ts": base_ts + i * 0.5,
|
||||||
|
"port": "/dev/cu.fake",
|
||||||
|
"role": "serial",
|
||||||
|
"from_node": "!abc",
|
||||||
|
"variant": "local",
|
||||||
|
"fields": {"heap_free_bytes": 10000 - i},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
out = log_query.telemetry_timeline(
|
||||||
|
window="2m", variant="local", field="free_heap", max_points=10
|
||||||
|
)
|
||||||
|
assert out["samples"] == 100
|
||||||
|
assert len(out["points"]) <= 10
|
||||||
|
# Negative slope (heap dropping). Magnitude: 1 byte every 0.5s = 120/min.
|
||||||
|
assert out["slope_per_min"] is not None
|
||||||
|
assert out["slope_per_min"] < -100
|
||||||
|
|
||||||
|
def test_export_bundles_slice(self, recorder: Recorder, tmp_path: Path) -> None:
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 1 [t] one", _FakeIface())
|
||||||
|
recorder._on_log_line("INFO | 00:00:00 2 [t] two", _FakeIface())
|
||||||
|
dest = tmp_path / "bundle"
|
||||||
|
out = log_query.export(start="-1m", end="now", dest_dir=str(dest))
|
||||||
|
assert (dest / "logs.jsonl").exists()
|
||||||
|
assert "logs" in out["paths"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- time parser -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTime:
|
||||||
|
def test_relative(self) -> None:
|
||||||
|
now = 1_000_000.0
|
||||||
|
assert log_query._parse_time("-15m", now=now) == now - 900
|
||||||
|
assert log_query._parse_time("-2h", now=now) == now - 7200
|
||||||
|
assert log_query._parse_time("-1d", now=now) == now - 86400
|
||||||
|
|
||||||
|
def test_now_and_epoch(self) -> None:
|
||||||
|
now = 1_000_000.0
|
||||||
|
assert log_query._parse_time("now", now=now) == now
|
||||||
|
assert log_query._parse_time(now) == now
|
||||||
|
|
||||||
|
def test_iso(self) -> None:
|
||||||
|
ts = log_query._parse_time("2026-01-01T00:00:00Z")
|
||||||
|
assert isinstance(ts, float) and ts > 1_700_000_000
|
||||||
|
|
||||||
|
def test_naive_iso_assumes_utc(self) -> None:
|
||||||
|
assert log_query._parse_time("2026-01-01T00:00:00") == log_query._parse_time(
|
||||||
|
"2026-01-01T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid(self) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
log_query._parse_time("not a time")
|
||||||
|
|
||||||
|
|
||||||
|
# -- rotation --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRotation:
|
||||||
|
def test_size_cap_rotates_and_gzips(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, max_bytes=512, keep_archives=5, check_every=1)
|
||||||
|
for i in range(100):
|
||||||
|
r.write({"ts": float(i), "i": i, "pad": "x" * 40})
|
||||||
|
r.close()
|
||||||
|
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert archives, "expected at least one rotation"
|
||||||
|
# Archive content is valid gzip + valid JSONL
|
||||||
|
with gzip.open(archives[0], "rt") as fh:
|
||||||
|
first = json.loads(fh.readline())
|
||||||
|
assert "ts" in first
|
||||||
|
|
||||||
|
def test_archive_pruning(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, max_bytes=200, keep_archives=2, check_every=1)
|
||||||
|
# Force several rotations.
|
||||||
|
for _ in range(8):
|
||||||
|
for i in range(20):
|
||||||
|
r.write({"ts": float(i), "pad": "x" * 30})
|
||||||
|
r.force_rotate()
|
||||||
|
r.close()
|
||||||
|
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert len(archives) <= 2, f"expected ≤2 kept archives, got {len(archives)}"
|
||||||
|
|
||||||
|
def test_archive_pruning_uses_filename_order(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, keep_archives=2)
|
||||||
|
old = tmp_path / "rot.20260101-000000-000000-00000.jsonl.gz"
|
||||||
|
mid = tmp_path / "rot.20260101-000001-000000-00000.jsonl.gz"
|
||||||
|
new = tmp_path / "rot.20260101-000002-000000-00000.jsonl.gz"
|
||||||
|
for archive in (old, mid, new):
|
||||||
|
with gzip.open(archive, "wt", encoding="utf-8") as fh:
|
||||||
|
fh.write('{"ts":1}\n')
|
||||||
|
# Deliberately scramble mtimes so lexicographic filename order is
|
||||||
|
# the only stable chronological signal.
|
||||||
|
os.utime(old, (300, 300))
|
||||||
|
os.utime(mid, (100, 100))
|
||||||
|
os.utime(new, (200, 200))
|
||||||
|
|
||||||
|
r._prune_archives()
|
||||||
|
r.close()
|
||||||
|
|
||||||
|
archives = sorted(p.name for p in tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert archives == [mid.name, new.name]
|
||||||
|
|
||||||
|
def test_force_rotate_when_below_threshold(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "rot.jsonl"
|
||||||
|
r = _RotatingJsonl(path, max_bytes=10_000_000, check_every=999_999)
|
||||||
|
r.write({"ts": 1.0, "msg": "tiny"})
|
||||||
|
r.force_rotate()
|
||||||
|
r.write({"ts": 2.0, "msg": "after-rotate"})
|
||||||
|
r.close()
|
||||||
|
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||||
|
assert len(archives) == 1
|
||||||
|
assert path.exists()
|
||||||
|
assert "after-rotate" in path.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorderLocks:
|
||||||
|
def test_force_rotate_all_returns_status(self, recorder: Recorder) -> None:
|
||||||
|
out = recorder.force_rotate_all()
|
||||||
|
assert out["running"] is True
|
||||||
|
assert out["files"]
|
||||||
|
|
||||||
|
def test_wire_pubsub_logs_subscription_failure(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
class FailingPubSubMock:
|
||||||
|
def subscribe(self, callback: object, topic: str) -> None:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pubsub, "pub", FailingPubSubMock())
|
||||||
|
recorder = Recorder(base_dir=tmp_path)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
recorder._wire_pubsub()
|
||||||
|
assert (
|
||||||
|
"Recorder failed to subscribe to meshtastic.log.line: boom" in caplog.text
|
||||||
|
)
|
||||||
+6
-7
@@ -2,7 +2,7 @@
|
|||||||
; https://docs.platformio.org/page/projectconf.html
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
[platformio]
|
[platformio]
|
||||||
default_envs = heltec-v3
|
default_envs = tbeam
|
||||||
|
|
||||||
extra_configs =
|
extra_configs =
|
||||||
variants/*/*.ini
|
variants/*/*.ini
|
||||||
@@ -57,6 +57,7 @@ build_flags = -Wno-missing-field-initializers
|
|||||||
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
||||||
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
|
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
|
||||||
-DMESHTASTIC_EXCLUDE_POWERMON=1
|
-DMESHTASTIC_EXCLUDE_POWERMON=1
|
||||||
|
-DMESHTASTIC_EXCLUDE_STATUS=1
|
||||||
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
|
-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
|
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
|
||||||
#-D OLED_PL=1
|
#-D OLED_PL=1
|
||||||
@@ -120,7 +121,7 @@ lib_deps =
|
|||||||
[radiolib_base]
|
[radiolib_base]
|
||||||
lib_deps =
|
lib_deps =
|
||||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||||
|
|
||||||
[device-ui_base]
|
[device-ui_base]
|
||||||
lib_deps =
|
lib_deps =
|
||||||
@@ -137,7 +138,7 @@ lib_deps =
|
|||||||
# renovate: datasource=github-tags depName=Adafruit GFX packageName=adafruit/Adafruit-GFX-Library
|
# 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
|
https://github.com/adafruit/Adafruit-GFX-Library/archive/refs/tags/1.12.6.zip
|
||||||
# renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel
|
# renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel
|
||||||
https://github.com/adafruit/Adafruit_NeoPixel/archive/refs/tags/1.15.4.zip
|
https://github.com/adafruit/Adafruit_NeoPixel/archive/1.15.5.zip
|
||||||
# renovate: datasource=github-tags depName=Adafruit SSD1306 packageName=adafruit/Adafruit_SSD1306
|
# renovate: datasource=github-tags depName=Adafruit SSD1306 packageName=adafruit/Adafruit_SSD1306
|
||||||
https://github.com/adafruit/Adafruit_SSD1306/archive/refs/tags/2.5.16.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
|
# renovate: datasource=github-tags depName=Adafruit BMP280 packageName=adafruit/Adafruit_BMP280_Library
|
||||||
@@ -170,8 +171,8 @@ lib_deps =
|
|||||||
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
||||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
# 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
|
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
||||||
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
|
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
|
||||||
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
|
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
|
||||||
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
||||||
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
||||||
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
||||||
@@ -226,8 +227,6 @@ lib_deps =
|
|||||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
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
|
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
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 sensors with BSEC2 (Bosch proprietary IAQ)
|
||||||
[environmental_extra]
|
[environmental_extra]
|
||||||
|
|||||||
+1
-1
Submodule protobufs updated: 1d6f1a71ff...59cb394dcf
+1
-25
@@ -14,28 +14,4 @@ const uint8_t FROMNUM_UUID_16[16u] = {0x53, 0x44, 0xe3, 0x47, 0x75, 0xaa, 0x70,
|
|||||||
const uint8_t LEGACY_LOGRADIO_UUID_16[16u] = {0xe2, 0xf2, 0x1e, 0xbe, 0xc5, 0x15, 0xcf, 0xaa,
|
const uint8_t LEGACY_LOGRADIO_UUID_16[16u] = {0xe2, 0xf2, 0x1e, 0xbe, 0xc5, 0x15, 0xcf, 0xaa,
|
||||||
0x6b, 0x43, 0xfa, 0x78, 0x38, 0xd2, 0x6f, 0x6c};
|
0x6b, 0x43, 0xfa, 0x78, 0x38, 0xd2, 0x6f, 0x6c};
|
||||||
const uint8_t LOGRADIO_UUID_16[16u] = {0x47, 0x95, 0xDF, 0x8C, 0xDE, 0xE9, 0x44, 0x99,
|
const uint8_t LOGRADIO_UUID_16[16u] = {0x47, 0x95, 0xDF, 0x8C, 0xDE, 0xE9, 0x44, 0x99,
|
||||||
0x23, 0x44, 0xE6, 0x06, 0x49, 0x6E, 0x3D, 0x5A};
|
0x23, 0x44, 0xE6, 0x06, 0x49, 0x6E, 0x3D, 0x5A};
|
||||||
|
|
||||||
void BluetoothApi::setup() {}
|
|
||||||
void BluetoothApi::shutdown() {}
|
|
||||||
void BluetoothApi::deinit() {}
|
|
||||||
void BluetoothApi::clearBonds() {}
|
|
||||||
bool BluetoothApi::isActive()
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
bool BluetoothApi::isConnected()
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
void BluetoothApi::sendLog(const uint8_t *logMessage, size_t length)
|
|
||||||
{
|
|
||||||
(void)logMessage;
|
|
||||||
(void)length;
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateBatteryLevel(uint8_t level) __attribute__((weak));
|
|
||||||
void updateBatteryLevel(uint8_t level)
|
|
||||||
{
|
|
||||||
(void)level;
|
|
||||||
}
|
|
||||||
@@ -24,14 +24,9 @@ void updateBatteryLevel(uint8_t level);
|
|||||||
class BluetoothApi
|
class BluetoothApi
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
virtual ~BluetoothApi() = default;
|
|
||||||
|
|
||||||
virtual void setup();
|
virtual void setup();
|
||||||
virtual void shutdown();
|
virtual void shutdown();
|
||||||
virtual void deinit();
|
|
||||||
virtual void clearBonds();
|
virtual void clearBonds();
|
||||||
virtual bool isActive();
|
|
||||||
virtual bool isConnected();
|
virtual bool isConnected();
|
||||||
virtual int getRssi() = 0;
|
virtual int getRssi() = 0;
|
||||||
virtual void sendLog(const uint8_t *logMessage, size_t length);
|
|
||||||
};
|
};
|
||||||
+16
-19
@@ -26,6 +26,8 @@ SOFTWARE.*/
|
|||||||
|
|
||||||
#include "DebugConfiguration.h"
|
#include "DebugConfiguration.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
#ifdef ARCH_PORTDUINO
|
#ifdef ARCH_PORTDUINO
|
||||||
#include "platform/portduino/PortduinoGlue.h"
|
#include "platform/portduino/PortduinoGlue.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -119,27 +121,22 @@ bool Syslog::vlogf(uint16_t pri, const char *fmt, va_list args)
|
|||||||
|
|
||||||
bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args)
|
bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args)
|
||||||
{
|
{
|
||||||
char *message;
|
// First measure the formatted length using a copy of args; passing args directly
|
||||||
size_t initialLen;
|
// to vsnprintf consumes it, and reusing a consumed va_list is undefined behavior.
|
||||||
size_t len;
|
va_list args_measure;
|
||||||
bool result;
|
va_copy(args_measure, args);
|
||||||
|
int needed = vsnprintf(nullptr, 0, fmt, args_measure);
|
||||||
|
va_end(args_measure);
|
||||||
|
|
||||||
initialLen = strlen(fmt);
|
if (needed < 0)
|
||||||
|
return false; // encoding error
|
||||||
|
|
||||||
message = new char[initialLen + 1];
|
auto message = std::unique_ptr<char[]>(new char[static_cast<size_t>(needed) + 1]);
|
||||||
|
int written = vsnprintf(message.get(), static_cast<size_t>(needed) + 1, fmt, args);
|
||||||
|
if (written < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
len = vsnprintf(message, initialLen + 1, fmt, args);
|
return this->_sendLog(pri, appName, message.get());
|
||||||
if (len > initialLen) {
|
|
||||||
delete[] message;
|
|
||||||
message = new char[len + 1];
|
|
||||||
|
|
||||||
vsnprintf(message, len + 1, fmt, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
result = this->_sendLog(pri, appName, message);
|
|
||||||
|
|
||||||
delete[] message;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message)
|
inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message)
|
||||||
@@ -154,7 +151,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess
|
|||||||
if (!this->_enabled)
|
if (!this->_enabled)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if ((this->_server == NULL && this->_ip == INADDR_NONE) || this->_port == 0)
|
if ((this->_server == NULL && this->_ip == IPAddress(0, 0, 0, 0)) || this->_port == 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Check priority against priMask values.
|
// Check priority against priMask values.
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ extern MemGet memGet;
|
|||||||
#define LED_STATE_ON 1
|
#define LED_STATE_ON 1
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// WIFI LED
|
||||||
|
#ifndef WIFI_STATE_ON
|
||||||
|
#define WIFI_STATE_ON 1
|
||||||
|
#endif
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// DEBUG
|
// DEBUG
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -147,12 +152,15 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
|
|||||||
// Default Bluetooth PIN
|
// Default Bluetooth PIN
|
||||||
#define defaultBLEPin 123456
|
#define defaultBLEPin 123456
|
||||||
|
|
||||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||||
|
#include <ESP32_CH390.h>
|
||||||
|
#elif HAS_ETHERNET && !defined(USE_WS5500)
|
||||||
#include <RAK13800_W5100S.h>
|
#include <RAK13800_W5100S.h>
|
||||||
#endif // HAS_ETHERNET
|
#endif // HAS_ETHERNET
|
||||||
|
|
||||||
#if HAS_ETHERNET && defined(ARCH_ESP32)
|
#if HAS_ETHERNET && defined(USE_WS5500)
|
||||||
#include <ETH.h>
|
#include <ETHClass2.h>
|
||||||
|
#define ETH ETH2
|
||||||
#endif // HAS_ETHERNET
|
#endif // HAS_ETHERNET
|
||||||
|
|
||||||
#if HAS_WIFI
|
#if HAS_WIFI
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
|||||||
bool usePreset)
|
bool usePreset)
|
||||||
{
|
{
|
||||||
|
|
||||||
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
|
// If use_preset is false, always return "Custom"
|
||||||
// rely on this being a stable literal for channel-name hashing and default-channel detection.
|
|
||||||
if (!usePreset) {
|
if (!usePreset) {
|
||||||
return "Custom";
|
return "Custom";
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-61
@@ -79,46 +79,28 @@ bool copyFile(const char *from, const char *to)
|
|||||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||||
{
|
{
|
||||||
#ifdef FSCom
|
#ifdef FSCom
|
||||||
|
|
||||||
|
#ifdef ARCH_ESP32
|
||||||
|
// take SPI Lock
|
||||||
spiLock->lock();
|
spiLock->lock();
|
||||||
|
// rename was fixed for ESP32 IDF LittleFS in April
|
||||||
bool result = FSCom.rename(pathFrom, pathTo);
|
bool result = FSCom.rename(pathFrom, pathTo);
|
||||||
spiLock->unlock();
|
spiLock->unlock();
|
||||||
return result;
|
return result;
|
||||||
#else
|
#else
|
||||||
return false;
|
// copyFile does its own locking.
|
||||||
|
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Platform-agnostic filesystem format / wipe.
|
|
||||||
*
|
|
||||||
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
|
|
||||||
* native FSCom.format() which erases and reinitialises the LittleFS
|
|
||||||
* partition.
|
|
||||||
*
|
|
||||||
* On Portduino the fs::FS backend has no format() method. We instead
|
|
||||||
* delete /prefs (the only meshtastic data directory written at runtime)
|
|
||||||
* and return. rmDir("/prefs") is already called unconditionally by
|
|
||||||
* factoryReset() so this is a proven primitive on Portduino.
|
|
||||||
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
|
|
||||||
*
|
|
||||||
* @return true on success, false on failure or if no filesystem is configured.
|
|
||||||
*/
|
|
||||||
bool fsFormat()
|
|
||||||
{
|
|
||||||
#ifdef FSCom
|
|
||||||
#if defined(ARCH_PORTDUINO)
|
|
||||||
rmDir("/prefs");
|
|
||||||
return FSBegin();
|
|
||||||
#else
|
|
||||||
return FSCom.format();
|
|
||||||
#endif
|
|
||||||
#else
|
|
||||||
return false;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get the list of files in a directory.
|
* @brief Get the list of files in a directory.
|
||||||
*
|
*
|
||||||
@@ -141,21 +123,23 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
|||||||
|
|
||||||
File file = root.openNextFile();
|
File file = root.openNextFile();
|
||||||
while (file) {
|
while (file) {
|
||||||
#ifdef ARCH_ESP32
|
|
||||||
const char *filepath = file.path();
|
|
||||||
#else
|
|
||||||
const char *filepath = file.name();
|
|
||||||
#endif
|
|
||||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||||
if (levels) {
|
if (levels) {
|
||||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
#ifdef ARCH_ESP32
|
||||||
|
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
|
||||||
|
#else
|
||||||
|
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
|
||||||
|
#endif
|
||||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
#ifdef ARCH_ESP32
|
||||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
strcpy(fileInfo.file_name, file.path());
|
||||||
|
#else
|
||||||
|
strcpy(fileInfo.file_name, file.name());
|
||||||
|
#endif
|
||||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||||
filenames.push_back(fileInfo);
|
filenames.push_back(fileInfo);
|
||||||
}
|
}
|
||||||
@@ -179,59 +163,98 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
|||||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||||
{
|
{
|
||||||
#ifdef FSCom
|
#ifdef FSCom
|
||||||
|
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||||
char buffer[255];
|
char buffer[255];
|
||||||
|
#endif
|
||||||
File root = FSCom.open(dirname, FILE_O_READ);
|
File root = FSCom.open(dirname, FILE_O_READ);
|
||||||
if (!root || !root.isDirectory())
|
if (!root) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
if (!root.isDirectory()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
File file = root.openNextFile();
|
File file = root.openNextFile();
|
||||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
while (
|
||||||
#ifdef ARCH_ESP32
|
file &&
|
||||||
const char *filepath = file.path();
|
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||||
#else
|
|
||||||
const char *filepath = file.name();
|
|
||||||
#endif
|
|
||||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||||
if (levels) {
|
if (levels) {
|
||||||
listDir(filepath, levels - 1, del);
|
#ifdef ARCH_ESP32
|
||||||
|
listDir(file.path(), levels - 1, del);
|
||||||
if (del) {
|
if (del) {
|
||||||
LOG_DEBUG("Remove %s", filepath);
|
LOG_DEBUG("Remove %s", file.path());
|
||||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
strncpy(buffer, file.path(), sizeof(buffer));
|
||||||
buffer[sizeof(buffer) - 1] = '\0';
|
|
||||||
file.close();
|
file.close();
|
||||||
FSCom.rmdir(buffer);
|
FSCom.rmdir(buffer);
|
||||||
} else {
|
} else {
|
||||||
file.close();
|
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 {
|
} else {
|
||||||
|
#ifdef ARCH_ESP32
|
||||||
if (del) {
|
if (del) {
|
||||||
LOG_DEBUG("Delete %s", filepath);
|
LOG_DEBUG("Delete %s", file.path());
|
||||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
strncpy(buffer, file.path(), sizeof(buffer));
|
||||||
buffer[sizeof(buffer) - 1] = '\0';
|
|
||||||
file.close();
|
file.close();
|
||||||
FSCom.remove(buffer);
|
FSCom.remove(buffer);
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||||
file.close();
|
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();
|
file = root.openNextFile();
|
||||||
}
|
}
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
const char *rootpath = root.path();
|
|
||||||
#else
|
|
||||||
const char *rootpath = root.name();
|
|
||||||
#endif
|
|
||||||
if (del) {
|
if (del) {
|
||||||
LOG_DEBUG("Remove %s", rootpath);
|
LOG_DEBUG("Remove %s", root.path());
|
||||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
strncpy(buffer, root.path(), sizeof(buffer));
|
||||||
buffer[sizeof(buffer) - 1] = '\0';
|
|
||||||
root.close();
|
root.close();
|
||||||
FSCom.rmdir(buffer);
|
FSCom.rmdir(buffer);
|
||||||
} else {
|
} else {
|
||||||
root.close();
|
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();
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
root.close();
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +268,14 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
|||||||
void rmDir(const char *dirname)
|
void rmDir(const char *dirname)
|
||||||
{
|
{
|
||||||
#ifdef FSCom
|
#ifdef FSCom
|
||||||
|
|
||||||
|
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||||
listDir(dirname, 10, true);
|
listDir(dirname, 10, true);
|
||||||
|
#elif defined(ARCH_NRF52)
|
||||||
|
// nRF52 implementation of LittleFS has a recursive delete function
|
||||||
|
FSCom.rmdir_r(dirname);
|
||||||
|
#endif
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +307,7 @@ void fsInit()
|
|||||||
*/
|
*/
|
||||||
void setupSDCard()
|
void setupSDCard()
|
||||||
{
|
{
|
||||||
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) && !defined(HAS_SD_MMC)
|
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI)
|
||||||
concurrency::LockGuard g(spiLock);
|
concurrency::LockGuard g(spiLock);
|
||||||
SDHandler.begin(SPI_SCK, SPI_MISO, SPI_MOSI);
|
SDHandler.begin(SPI_SCK, SPI_MISO, SPI_MOSI);
|
||||||
if (!SD.begin(SDCARD_CS, SDHandler, SD_SPI_FREQUENCY)) {
|
if (!SD.begin(SDCARD_CS, SDHandler, SD_SPI_FREQUENCY)) {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ void fsInit();
|
|||||||
void fsListFiles();
|
void fsListFiles();
|
||||||
bool copyFile(const char *from, const char *to);
|
bool copyFile(const char *from, const char *to);
|
||||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||||
bool fsFormat();
|
|
||||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||||
void rmDir(const char *dirname);
|
void rmDir(const char *dirname);
|
||||||
|
|||||||
+165
-208
@@ -23,14 +23,8 @@
|
|||||||
#include "main.h"
|
#include "main.h"
|
||||||
#include "meshUtils.h"
|
#include "meshUtils.h"
|
||||||
#include "power/PowerHAL.h"
|
#include "power/PowerHAL.h"
|
||||||
|
#include "power/SGM41562.h"
|
||||||
#include "sleep.h"
|
#include "sleep.h"
|
||||||
#ifdef ARCH_ESP32
|
|
||||||
// #include <driver/adc.h>
|
|
||||||
#include <esp_adc/adc_cali.h>
|
|
||||||
#include <esp_adc/adc_cali_scheme.h>
|
|
||||||
#include <esp_adc/adc_oneshot.h>
|
|
||||||
#include <esp_err.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(ARCH_PORTDUINO)
|
#if defined(ARCH_PORTDUINO)
|
||||||
#include "api/WiFiServerAPI.h"
|
#include "api/WiFiServerAPI.h"
|
||||||
@@ -47,22 +41,6 @@
|
|||||||
#include "concurrency/LockGuard.h"
|
#include "concurrency/LockGuard.h"
|
||||||
#endif
|
#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
|
#if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT
|
||||||
#include "mqtt/MQTT.h"
|
#include "mqtt/MQTT.h"
|
||||||
#include "target_specific.h"
|
#include "target_specific.h"
|
||||||
@@ -70,8 +48,9 @@
|
|||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if HAS_ETHERNET && defined(ARCH_ESP32)
|
#if HAS_ETHERNET && defined(USE_WS5500)
|
||||||
#include <ETH.h>
|
#include <ETHClass2.h>
|
||||||
|
#define ETH ETH2
|
||||||
#endif // HAS_ETHERNET
|
#endif // HAS_ETHERNET
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -83,113 +62,33 @@
|
|||||||
#if defined(BATTERY_PIN) && defined(ARCH_ESP32)
|
#if defined(BATTERY_PIN) && defined(ARCH_ESP32)
|
||||||
|
|
||||||
#ifndef BAT_MEASURE_ADC_UNIT // ADC1 is default
|
#ifndef BAT_MEASURE_ADC_UNIT // ADC1 is default
|
||||||
static const adc_channel_t adc_channel = ADC_CHANNEL;
|
static const adc1_channel_t adc_channel = ADC_CHANNEL;
|
||||||
static const adc_unit_t unit = ADC_UNIT_1;
|
static const adc_unit_t unit = ADC_UNIT_1;
|
||||||
#else // ADC2
|
#else // ADC2
|
||||||
static const adc_channel_t adc_channel = ADC_CHANNEL;
|
static const adc2_channel_t adc_channel = ADC_CHANNEL;
|
||||||
static const adc_unit_t unit = ADC_UNIT_2;
|
static const adc_unit_t unit = ADC_UNIT_2;
|
||||||
|
RTC_NOINIT_ATTR uint64_t RTC_reg_b;
|
||||||
|
|
||||||
#endif // BAT_MEASURE_ADC_UNIT
|
#endif // BAT_MEASURE_ADC_UNIT
|
||||||
|
|
||||||
static adc_oneshot_unit_handle_t adc_handle = nullptr;
|
esp_adc_cal_characteristics_t *adc_characs = (esp_adc_cal_characteristics_t *)calloc(1, sizeof(esp_adc_cal_characteristics_t));
|
||||||
static adc_cali_handle_t adc_cali_handle = nullptr;
|
|
||||||
static bool adc_calibrated = false;
|
|
||||||
#ifndef ADC_ATTENUATION
|
#ifndef ADC_ATTENUATION
|
||||||
static const adc_atten_t atten = ADC_ATTEN_DB_12;
|
static const adc_atten_t atten = ADC_ATTEN_DB_12;
|
||||||
#else
|
#else
|
||||||
static const adc_atten_t atten = ADC_ATTENUATION;
|
static const adc_atten_t atten = ADC_ATTENUATION;
|
||||||
#endif
|
#endif
|
||||||
#ifdef ADC_BITWIDTH
|
|
||||||
static const adc_bitwidth_t adc_width = ADC_BITWIDTH;
|
|
||||||
#else
|
|
||||||
static const adc_bitwidth_t adc_width = ADC_BITWIDTH_DEFAULT;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static int adcBitWidthToBits(adc_bitwidth_t width)
|
|
||||||
{
|
|
||||||
switch (width) {
|
|
||||||
case ADC_BITWIDTH_9:
|
|
||||||
return 9;
|
|
||||||
case ADC_BITWIDTH_10:
|
|
||||||
return 10;
|
|
||||||
case ADC_BITWIDTH_11:
|
|
||||||
return 11;
|
|
||||||
case ADC_BITWIDTH_12:
|
|
||||||
return 12;
|
|
||||||
#ifdef ADC_BITWIDTH_13
|
|
||||||
case ADC_BITWIDTH_13:
|
|
||||||
return 13;
|
|
||||||
#endif
|
|
||||||
default:
|
|
||||||
return 12;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool initAdcCalibration()
|
|
||||||
{
|
|
||||||
#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
|
|
||||||
adc_cali_curve_fitting_config_t cali_config = {
|
|
||||||
.unit_id = unit,
|
|
||||||
.atten = atten,
|
|
||||||
.bitwidth = adc_width,
|
|
||||||
};
|
|
||||||
esp_err_t ret = adc_cali_create_scheme_curve_fitting(&cali_config, &adc_cali_handle);
|
|
||||||
if (ret == ESP_OK) {
|
|
||||||
LOG_INFO("ADC calibration: curve fitting enabled");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (ret != ESP_ERR_NOT_SUPPORTED) {
|
|
||||||
LOG_WARN("ADC calibration: curve fitting failed: %s", esp_err_to_name(ret));
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED
|
|
||||||
adc_cali_line_fitting_config_t cali_config = {
|
|
||||||
.unit_id = unit,
|
|
||||||
.atten = atten,
|
|
||||||
.bitwidth = adc_width,
|
|
||||||
.default_vref = DEFAULT_VREF,
|
|
||||||
};
|
|
||||||
esp_err_t ret = adc_cali_create_scheme_line_fitting(&cali_config, &adc_cali_handle);
|
|
||||||
if (ret == ESP_OK) {
|
|
||||||
LOG_INFO("ADC calibration: line fitting enabled");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (ret != ESP_ERR_NOT_SUPPORTED) {
|
|
||||||
LOG_WARN("ADC calibration: line fitting failed: %s", esp_err_to_name(ret));
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
LOG_INFO("ADC calibration not supported; using approximate scaling");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif // BATTERY_PIN && ARCH_ESP32
|
#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
|
#ifdef EXT_CHRG_DETECT
|
||||||
#ifndef EXT_CHRG_DETECT_MODE
|
#ifndef EXT_CHRG_DETECT_MODE
|
||||||
#define EXT_CHRG_DETECT_MODE INPUT
|
static const uint8_t ext_chrg_detect_mode = INPUT;
|
||||||
// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE
|
#else
|
||||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP
|
static const uint8_t ext_chrg_detect_mode = EXT_CHRG_DETECT_MODE;
|
||||||
#define EXT_CHRG_DETECT_VALUE LOW
|
|
||||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN
|
|
||||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
|
||||||
#endif
|
#endif
|
||||||
#ifndef EXT_CHRG_DETECT_VALUE
|
#ifndef EXT_CHRG_DETECT_VALUE
|
||||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
static const uint8_t ext_chrg_detect_value = HIGH;
|
||||||
|
#else
|
||||||
|
static const uint8_t ext_chrg_detect_value = EXT_CHRG_DETECT_VALUE;
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -430,29 +329,11 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
|||||||
float scaled = 0;
|
float scaled = 0;
|
||||||
|
|
||||||
battery_adcEnable();
|
battery_adcEnable();
|
||||||
#ifdef ARCH_STM32WL
|
#ifdef ARCH_ESP32 // ADC block for espressif platforms
|
||||||
// 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();
|
raw = espAdcRead();
|
||||||
int voltage_mv = 0;
|
scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs);
|
||||||
if (adc_calibrated && adc_cali_handle) {
|
scaled *= operativeAdcMultiplier;
|
||||||
if (adc_cali_raw_to_voltage(adc_cali_handle, raw, &voltage_mv) != ESP_OK) {
|
#else // block for all other platforms
|
||||||
LOG_WARN("ADC calibration read failed; using raw value");
|
|
||||||
voltage_mv = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (voltage_mv == 0) {
|
|
||||||
// Fallback approximate conversion without calibration
|
|
||||||
const int bits = adcBitWidthToBits(adc_width);
|
|
||||||
const float max_code = powf(2.0f, bits) - 1.0f;
|
|
||||||
voltage_mv = (int)((raw / max_code) * DEFAULT_VREF);
|
|
||||||
}
|
|
||||||
scaled = voltage_mv * operativeAdcMultiplier;
|
|
||||||
#else // block for all other platforms
|
|
||||||
#ifdef ARCH_NRF52
|
#ifdef ARCH_NRF52
|
||||||
concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock);
|
concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock);
|
||||||
#endif
|
#endif
|
||||||
@@ -493,22 +374,51 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
|||||||
uint32_t raw = 0;
|
uint32_t raw = 0;
|
||||||
uint8_t raw_c = 0; // raw reading counter
|
uint8_t raw_c = 0; // raw reading counter
|
||||||
|
|
||||||
if (!adc_handle) {
|
#ifndef BAT_MEASURE_ADC_UNIT // ADC1
|
||||||
LOG_ERROR("ADC oneshot handle not initialized");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) {
|
for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) {
|
||||||
int val = 0;
|
int val_ = adc1_get_raw(adc_channel);
|
||||||
esp_err_t err = adc_oneshot_read(adc_handle, adc_channel, &val);
|
if (val_ >= 0) { // save only valid readings
|
||||||
if (err == ESP_OK) {
|
raw += val_;
|
||||||
raw += val;
|
|
||||||
raw_c++;
|
raw_c++;
|
||||||
|
}
|
||||||
|
// delayMicroseconds(100);
|
||||||
|
}
|
||||||
|
#else // ADC2
|
||||||
|
#ifdef CONFIG_IDF_TARGET_ESP32S3 // ESP32S3
|
||||||
|
// ADC2 wifi bug workaround not required, breaks compile
|
||||||
|
// On ESP32S3, ADC2 can take turns with Wifi (?)
|
||||||
|
|
||||||
|
int32_t adc_buf;
|
||||||
|
esp_err_t read_result;
|
||||||
|
|
||||||
|
// Multiple samples
|
||||||
|
for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) {
|
||||||
|
adc_buf = 0;
|
||||||
|
read_result = -1;
|
||||||
|
|
||||||
|
read_result = adc2_get_raw(adc_channel, ADC_WIDTH_BIT_12, &adc_buf);
|
||||||
|
if (read_result == ESP_OK) {
|
||||||
|
raw += adc_buf;
|
||||||
|
raw_c++; // Count valid samples
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG("ADC read failed: %s", esp_err_to_name(err));
|
LOG_DEBUG("An attempt to sample ADC2 failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#else // Other ESP32
|
||||||
|
int32_t adc_buf = 0;
|
||||||
|
for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) {
|
||||||
|
// ADC2 wifi bug workaround, see
|
||||||
|
// https://github.com/espressif/arduino-esp32/issues/102
|
||||||
|
WRITE_PERI_REG(SENS_SAR_READ_CTRL2_REG, RTC_reg_b);
|
||||||
|
SET_PERI_REG_MASK(SENS_SAR_READ_CTRL2_REG, SENS_SAR2_DATA_INV);
|
||||||
|
adc2_get_raw(adc_channel, ADC_WIDTH_BIT_12, &adc_buf);
|
||||||
|
raw += adc_buf;
|
||||||
|
raw_c++;
|
||||||
|
}
|
||||||
|
#endif // BAT_MEASURE_ADC_UNIT
|
||||||
|
|
||||||
|
#endif // End BAT_MEASURE_ADC_UNIT
|
||||||
return (raw / (raw_c < 1 ? 1 : raw_c));
|
return (raw / (raw_c < 1 ? 1 : raw_c));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -538,14 +448,32 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
|||||||
virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; }
|
virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Detect if an external power source is connected if we don’t have a PMIC;
|
/// If we see a battery voltage higher than physics allows - assume charger is
|
||||||
// Firstly prefer EXT_PWR_DETECT GPIO if available,
|
/// pumping in power On some boards we don't have the power management chip
|
||||||
// secondly try an nRF52-specific routine on some variants,
|
/// (like AXPxxxx) so we use EXT_PWR_DETECT GPIO pin to detect external power
|
||||||
// lastly provide a fallback to indicate external power when fully charged.
|
/// source
|
||||||
virtual bool isVbusIn() override
|
virtual bool isVbusIn() override
|
||||||
{
|
{
|
||||||
|
#ifdef HAS_SGM41562
|
||||||
|
if (sgm41562 && sgm41562->refresh())
|
||||||
|
return sgm41562->isInputPowerGood();
|
||||||
|
#endif
|
||||||
#ifdef EXT_PWR_DETECT
|
#ifdef EXT_PWR_DETECT
|
||||||
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
|
#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;
|
||||||
|
|
||||||
// technically speaking this should work for all(?) NRF52 boards
|
// technically speaking this should work for all(?) NRF52 boards
|
||||||
// but needs testing across multiple devices. NRF52 USB would not even work if
|
// but needs testing across multiple devices. NRF52 USB would not even work if
|
||||||
@@ -560,15 +488,19 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
|||||||
/// we can't be smart enough to say 'full'?
|
/// we can't be smart enough to say 'full'?
|
||||||
virtual bool isCharging() override
|
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 HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT) && !defined(HAS_PMU)
|
||||||
if (hasRAK()) {
|
if (hasRAK()) {
|
||||||
return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse;
|
return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if defined(ELECROW_ThinkNode_M6)
|
#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
|
#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)
|
#elif defined(BATTERY_CHARGING_INV)
|
||||||
return !digitalRead(BATTERY_CHARGING_INV);
|
return !digitalRead(BATTERY_CHARGING_INV);
|
||||||
#else
|
#else
|
||||||
@@ -607,11 +539,6 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
|||||||
bool initial_read_done = false;
|
bool initial_read_done = false;
|
||||||
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
||||||
uint32_t last_read_time_ms = 0;
|
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)
|
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT)
|
||||||
|
|
||||||
@@ -701,10 +628,14 @@ Power::Power() : OSThread("Power")
|
|||||||
bool Power::analogInit()
|
bool Power::analogInit()
|
||||||
{
|
{
|
||||||
#ifdef EXT_PWR_DETECT
|
#ifdef EXT_PWR_DETECT
|
||||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||||
|
pinMode(EXT_PWR_DETECT, INPUT_PULLUP);
|
||||||
|
#else
|
||||||
|
pinMode(EXT_PWR_DETECT, INPUT);
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
#ifdef EXT_CHRG_DETECT
|
#ifdef EXT_CHRG_DETECT
|
||||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef BATTERY_PIN
|
#ifdef BATTERY_PIN
|
||||||
@@ -717,38 +648,47 @@ bool Power::analogInit()
|
|||||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef ARCH_STM32WL
|
#ifdef ARCH_ESP32 // ESP32 needs special analog stuff
|
||||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
|
||||||
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
|
|
||||||
adc_oneshot_unit_init_cfg_t init_config = {
|
|
||||||
.unit_id = unit,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!adc_handle) {
|
#ifndef ADC_WIDTH // max resolution by default
|
||||||
esp_err_t err = adc_oneshot_new_unit(&init_config, &adc_handle);
|
static const adc_bits_width_t width = ADC_WIDTH_BIT_12;
|
||||||
if (err != ESP_OK) {
|
#else
|
||||||
LOG_ERROR("ADC oneshot init failed: %s", esp_err_to_name(err));
|
static const adc_bits_width_t width = ADC_WIDTH;
|
||||||
return false;
|
#endif
|
||||||
}
|
#ifndef BAT_MEASURE_ADC_UNIT // ADC1
|
||||||
|
adc1_config_width(width);
|
||||||
|
adc1_config_channel_atten(adc_channel, atten);
|
||||||
|
#else // ADC2
|
||||||
|
adc2_config_channel_atten(adc_channel, atten);
|
||||||
|
#ifndef CONFIG_IDF_TARGET_ESP32S3
|
||||||
|
// ADC2 wifi bug workaround
|
||||||
|
// Not required with ESP32S3, breaks compile
|
||||||
|
RTC_reg_b = READ_PERI_REG(SENS_SAR_READ_CTRL2_REG);
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
// calibrate ADC
|
||||||
|
esp_adc_cal_value_t val_type = esp_adc_cal_characterize(unit, atten, width, DEFAULT_VREF, adc_characs);
|
||||||
|
// show ADC characterization base
|
||||||
|
if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP) {
|
||||||
|
LOG_INFO("ADC config based on Two Point values stored in eFuse");
|
||||||
|
} else if (val_type == ESP_ADC_CAL_VAL_EFUSE_VREF) {
|
||||||
|
LOG_INFO("ADC config based on reference voltage stored in eFuse");
|
||||||
}
|
}
|
||||||
|
#ifdef CONFIG_IDF_TARGET_ESP32S3
|
||||||
adc_oneshot_chan_cfg_t chan_cfg = {
|
// ESP32S3
|
||||||
.atten = atten,
|
else if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP_FIT) {
|
||||||
.bitwidth = adc_width,
|
LOG_INFO("ADC config based on Two Point values and fitting curve "
|
||||||
};
|
"coefficients stored in eFuse");
|
||||||
|
|
||||||
esp_err_t err = adc_oneshot_config_channel(adc_handle, adc_channel, &chan_cfg);
|
|
||||||
if (err != ESP_OK) {
|
|
||||||
LOG_ERROR("ADC channel config failed: %s", esp_err_to_name(err));
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
adc_calibrated = initAdcCalibration();
|
else {
|
||||||
#endif // ARCH_ESP32
|
LOG_INFO("ADC config based on default reference voltage");
|
||||||
|
}
|
||||||
|
#endif // ARCH_ESP32
|
||||||
|
|
||||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||||
|
|
||||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
#ifndef ARCH_ESP32
|
||||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -766,6 +706,12 @@ bool Power::analogInit()
|
|||||||
*/
|
*/
|
||||||
bool Power::setup()
|
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;
|
bool found = false;
|
||||||
if (axpChipInit()) {
|
if (axpChipInit()) {
|
||||||
found = true;
|
found = true;
|
||||||
@@ -947,7 +893,16 @@ void Power::readPowerStatus()
|
|||||||
|
|
||||||
// Notify any status instances that are observing us
|
// Notify any status instances that are observing us
|
||||||
const PowerStatus powerStatus2 = PowerStatus(hasBattery, usbPowered, isChargingNow, batteryVoltageMv, batteryChargePercent);
|
const PowerStatus powerStatus2 = PowerStatus(hasBattery, usbPowered, isChargingNow, batteryVoltageMv, batteryChargePercent);
|
||||||
if (millis() > lastLogTime + 50 * 1000) {
|
|
||||||
|
// 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)) {
|
||||||
LOG_DEBUG("Battery: usbPower=%d, isCharging=%d, batMv=%d, batPct=%d", powerStatus2.getHasUSB(),
|
LOG_DEBUG("Battery: usbPower=%d, isCharging=%d, batMv=%d, batPct=%d", powerStatus2.getHasUSB(),
|
||||||
powerStatus2.getIsCharging(), powerStatus2.getBatteryVoltageMv(), powerStatus2.getBatteryChargePercent());
|
powerStatus2.getIsCharging(), powerStatus2.getBatteryVoltageMv(), powerStatus2.getBatteryChargePercent());
|
||||||
lastLogTime = millis();
|
lastLogTime = millis();
|
||||||
@@ -1045,14 +1000,6 @@ int32_t Power::runOnce()
|
|||||||
powerFSM.trigger(EVENT_POWER_CONNECTED);
|
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...
|
Other things we could check if we cared...
|
||||||
|
|
||||||
@@ -1069,6 +1016,13 @@ int32_t Power::runOnce()
|
|||||||
LOG_DEBUG("Battery removed");
|
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();
|
PMU->clearIrqStatus();
|
||||||
}
|
}
|
||||||
@@ -1137,7 +1091,7 @@ void Power::attachPowerInterrupts()
|
|||||||
if (PMU) {
|
if (PMU) {
|
||||||
attachInterrupt(
|
attachInterrupt(
|
||||||
PMU_IRQ,
|
PMU_IRQ,
|
||||||
[]() {
|
[] {
|
||||||
pmu_irq = true;
|
pmu_irq = true;
|
||||||
power->setIntervalFromNow(0);
|
power->setIntervalFromNow(0);
|
||||||
runASAP = true;
|
runASAP = true;
|
||||||
@@ -1440,16 +1394,19 @@ bool Power::axpChipInit()
|
|||||||
uint64_t pmuIrqMask = 0;
|
uint64_t pmuIrqMask = 0;
|
||||||
|
|
||||||
if (PMU->getChipModel() == XPOWERS_AXP192) {
|
if (PMU->getChipModel() == XPOWERS_AXP192) {
|
||||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_VBUS_REMOVE_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_BAT_INSERT_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||||
} else if (PMU->getChipModel() == XPOWERS_AXP2101) {
|
} else if (PMU->getChipModel() == XPOWERS_AXP2101) {
|
||||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_VBUS_REMOVE_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_BAT_INSERT_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||||
}
|
}
|
||||||
|
|
||||||
pinMode(PMU_IRQ, INPUT);
|
pinMode(PMU_IRQ, INPUT);
|
||||||
|
|
||||||
// We wake on IRQ, so only enable the IRQs that we care about.
|
// we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ
|
||||||
// we want USB plug and unplug to update the screen and LED status,
|
// because it occurs repeatedly while there is no battery also it could cause
|
||||||
// and short press on the power button to trigger the "cancel" action in the UI (which also turns the screen on and off).
|
// 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
|
||||||
PMU->enableIRQ(pmuIrqMask);
|
PMU->enableIRQ(pmuIrqMask);
|
||||||
|
|
||||||
PMU->clearIrqStatus();
|
PMU->clearIrqStatus();
|
||||||
@@ -1915,7 +1872,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
|||||||
{
|
{
|
||||||
#if defined(EXT_CHRG_DETECT)
|
#if defined(EXT_CHRG_DETECT)
|
||||||
|
|
||||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
return false;
|
return false;
|
||||||
@@ -1924,7 +1881,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
|||||||
virtual bool isCharging() override
|
virtual bool isCharging() override
|
||||||
{
|
{
|
||||||
#ifdef EXT_CHRG_DETECT
|
#ifdef EXT_CHRG_DETECT
|
||||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
// by default, we check the battery voltage only
|
// by default, we check the battery voltage only
|
||||||
@@ -1946,10 +1903,10 @@ SerialBatteryLevel serialBatteryLevel;
|
|||||||
bool Power::serialBatteryInit()
|
bool Power::serialBatteryInit()
|
||||||
{
|
{
|
||||||
#ifdef EXT_PWR_DETECT
|
#ifdef EXT_PWR_DETECT
|
||||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
pinMode(EXT_PWR_DETECT, INPUT);
|
||||||
#endif
|
#endif
|
||||||
#ifdef EXT_CHRG_DETECT
|
#ifdef EXT_CHRG_DETECT
|
||||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
bool result = serialBatteryLevel.runOnce();
|
bool result = serialBatteryLevel.runOnce();
|
||||||
|
|||||||
+11
-48
@@ -58,35 +58,6 @@ static bool isPowered()
|
|||||||
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
|
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()
|
static void sdsEnter()
|
||||||
{
|
{
|
||||||
LOG_POWERFSM("State: SDS");
|
LOG_POWERFSM("State: SDS");
|
||||||
@@ -116,7 +87,6 @@ static void lsEnter()
|
|||||||
LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs);
|
LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs);
|
||||||
if (screen)
|
if (screen)
|
||||||
screen->setOn(false);
|
screen->setOn(false);
|
||||||
t5BacklightOffForSleep();
|
|
||||||
secsSlept = 0; // How long have we been sleeping this time
|
secsSlept = 0; // How long have we been sleeping this time
|
||||||
|
|
||||||
// LOG_INFO("lsEnter end");
|
// LOG_INFO("lsEnter end");
|
||||||
@@ -189,8 +159,6 @@ static void lsIdle()
|
|||||||
static void lsExit()
|
static void lsExit()
|
||||||
{
|
{
|
||||||
LOG_POWERFSM("State: lsExit");
|
LOG_POWERFSM("State: lsExit");
|
||||||
// Lift the light-sleep force-off gate when leaving LS.
|
|
||||||
t5BacklightWakeFromSleep();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void nbEnter()
|
static void nbEnter()
|
||||||
@@ -212,8 +180,6 @@ static void darkEnter()
|
|||||||
setBluetoothEnable(true);
|
setBluetoothEnable(true);
|
||||||
if (screen)
|
if (screen)
|
||||||
screen->setOn(false);
|
screen->setOn(false);
|
||||||
// Screen timeout enters DARK; ensure backlight also turns off.
|
|
||||||
t5BacklightOffForTimeout();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void serialEnter()
|
static void serialEnter()
|
||||||
@@ -323,13 +289,12 @@ void PowerFSM_setup()
|
|||||||
powerFSM.add_transition(&stateNB, &stateNB, EVENT_PACKET_FOR_PHONE, NULL, "Received packet, resetting win wake");
|
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
|
// Handle press events - note: we ignore button presses when in API mode
|
||||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press");
|
||||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press");
|
||||||
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press");
|
||||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press");
|
||||||
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput,
|
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers
|
||||||
"Press"); // reenter On to restart our timers
|
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL,
|
||||||
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, t5BacklightOnFromUserInput,
|
|
||||||
"Press"); // Allow button to work while in serial API
|
"Press"); // Allow button to work while in serial API
|
||||||
|
|
||||||
// Handle critically low power battery by forcing deep sleep
|
// Handle critically low power battery by forcing deep sleep
|
||||||
@@ -349,13 +314,11 @@ void PowerFSM_setup()
|
|||||||
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
|
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
|
||||||
|
|
||||||
// Inputbroker
|
// Inputbroker
|
||||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||||
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput,
|
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
|
||||||
"Input Device"); // restarts the sleep timer
|
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, NULL, "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(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||||
powerFSM.add_transition(&stateON, &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) {
|
if (color) {
|
||||||
::printf("\u001b[0m");
|
::printf("\u001b[0m");
|
||||||
}
|
}
|
||||||
::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, millis() % 1000);
|
::printf("| %02d:%02d:%02d %u ", hour, min, sec, millis() / 1000);
|
||||||
#else
|
#else
|
||||||
printf("%s ", logLevel);
|
printf("%s ", logLevel);
|
||||||
if (color) {
|
if (color) {
|
||||||
@@ -151,7 +151,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
|||||||
if (color) {
|
if (color) {
|
||||||
::printf("\u001b[0m");
|
::printf("\u001b[0m");
|
||||||
}
|
}
|
||||||
::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000);
|
::printf("| ??:??:?? %u ", millis() / 1000);
|
||||||
#else
|
#else
|
||||||
printf("%s ", logLevel);
|
printf("%s ", logLevel);
|
||||||
if (color) {
|
if (color) {
|
||||||
@@ -222,7 +222,7 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_
|
|||||||
if (config.security.debug_log_api_enabled && !pauseBluetoothLogging) {
|
if (config.security.debug_log_api_enabled && !pauseBluetoothLogging) {
|
||||||
bool isBleConnected = false;
|
bool isBleConnected = false;
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
isBleConnected = bluetoothApi && bluetoothApi->isActive() && bluetoothApi->isConnected();
|
isBleConnected = nimbleBluetooth && nimbleBluetooth->isActive() && nimbleBluetooth->isConnected();
|
||||||
#elif defined(ARCH_NRF52)
|
#elif defined(ARCH_NRF52)
|
||||||
isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected();
|
isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected();
|
||||||
#endif
|
#endif
|
||||||
@@ -238,7 +238,7 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_
|
|||||||
auto buffer = std::unique_ptr<uint8_t[]>(new uint8_t[meshtastic_LogRecord_size]);
|
auto buffer = std::unique_ptr<uint8_t[]>(new uint8_t[meshtastic_LogRecord_size]);
|
||||||
size_t size = pb_encode_to_bytes(buffer.get(), meshtastic_LogRecord_size, meshtastic_LogRecord_fields, &logRecord);
|
size_t size = pb_encode_to_bytes(buffer.get(), meshtastic_LogRecord_size, meshtastic_LogRecord_fields, &logRecord);
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
bluetoothApi->sendLog(buffer.get(), size);
|
nimbleBluetooth->sendLog(buffer.get(), size);
|
||||||
#elif defined(ARCH_NRF52)
|
#elif defined(ARCH_NRF52)
|
||||||
nrf52Bluetooth->sendLog(buffer.get(), size);
|
nrf52Bluetooth->sendLog(buffer.get(), size);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ static File openFile(const char *filename, bool fullAtomic)
|
|||||||
{
|
{
|
||||||
concurrency::LockGuard g(spiLock);
|
concurrency::LockGuard g(spiLock);
|
||||||
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
||||||
|
#ifdef ARCH_NRF52
|
||||||
|
FSCom.remove(filename);
|
||||||
|
return FSCom.open(filename, FILE_O_WRITE);
|
||||||
|
#endif
|
||||||
if (!fullAtomic) {
|
if (!fullAtomic) {
|
||||||
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
||||||
}
|
}
|
||||||
@@ -63,6 +67,9 @@ bool SafeFile::close()
|
|||||||
f.close();
|
f.close();
|
||||||
spiLock->unlock();
|
spiLock->unlock();
|
||||||
|
|
||||||
|
#ifdef ARCH_NRF52
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
if (!testReadback())
|
if (!testReadback())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ SerialConsole *console;
|
|||||||
|
|
||||||
void consoleInit()
|
void consoleInit()
|
||||||
{
|
{
|
||||||
if (console) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread
|
auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread
|
||||||
|
|
||||||
#if defined(SERIAL_HAS_ON_RECEIVE)
|
#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.
|
||||||
|
|
||||||
TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including
|
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel, including
|
||||||
other lora radios.
|
other lora radios.
|
||||||
|
|
||||||
RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel.
|
RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,762 +0,0 @@
|
|||||||
#include "configuration.h"
|
|
||||||
|
|
||||||
#if defined(CONFIG_IDF_TARGET_ESP32P4) && defined(CONFIG_ESP_HOSTED_ENABLED) && !MESHTASTIC_EXCLUDE_BLUETOOTH
|
|
||||||
#include "BluetoothStatus.h"
|
|
||||||
#include "PowerFSM.h"
|
|
||||||
#include "bluetooth/HostedBluetooth.h"
|
|
||||||
#include "concurrency/OSThread.h"
|
|
||||||
#include "esp32-hal-hosted.h"
|
|
||||||
#include "esp_err.h"
|
|
||||||
#include "esp_event.h"
|
|
||||||
#include "esp_hosted.h"
|
|
||||||
#include "esp_hosted_event.h"
|
|
||||||
#include "main.h"
|
|
||||||
#include "mesh/PhoneAPI.h"
|
|
||||||
#include "mesh/mesh-pb-constants.h"
|
|
||||||
#include <BLEAdvertising.h>
|
|
||||||
#include <BLEDevice.h>
|
|
||||||
#include <BLESecurity.h>
|
|
||||||
#include <BLEServer.h>
|
|
||||||
#include <BLEUtils.h>
|
|
||||||
#include <array>
|
|
||||||
#include <atomic>
|
|
||||||
#include <climits>
|
|
||||||
#include <cstring>
|
|
||||||
#include <driver/gpio.h>
|
|
||||||
#include <mutex>
|
|
||||||
|
|
||||||
#include "host/ble_gap.h"
|
|
||||||
#include "host/ble_store.h"
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* Maintainer note: HostedBluetooth intentionally stays close to NimbleBluetooth
|
|
||||||
* but is not a strict drop-in equivalent.
|
|
||||||
*
|
|
||||||
* Intentional differences from NimbleBluetooth include:
|
|
||||||
* - ESP-Hosted transport lifecycle handling (event callbacks + CP reset GPIO control).
|
|
||||||
* - Data-length update behavior (Hosted currently requests ble_gap_set_data_len on connect).
|
|
||||||
* - No Battery Service exposure here.
|
|
||||||
*
|
|
||||||
* If you modify common BLE flow in one class, review and likely mirror in both:
|
|
||||||
* - PhoneAPI queueing/synchronization between BLE callbacks and runOnce().
|
|
||||||
* - Security/pairing config and passkey UX/status updates.
|
|
||||||
* - Mesh GATT characteristics, permissions, and advertising setup.
|
|
||||||
* - Connection parameter tuning and reconnect/disconnect cleanup paths.
|
|
||||||
*/
|
|
||||||
constexpr uint16_t kPreferredBleMtu = 517;
|
|
||||||
constexpr uint16_t kPreferredBleTxOctets = 251;
|
|
||||||
constexpr uint16_t kPreferredBleTxTimeUs = (kPreferredBleTxOctets + 14) * 8;
|
|
||||||
constexpr size_t kBluetoothToPhoneQueueSize = 3;
|
|
||||||
constexpr size_t kBluetoothFromPhoneQueueSize = 3;
|
|
||||||
|
|
||||||
BLECharacteristic *fromNumCharacteristic = nullptr;
|
|
||||||
BLECharacteristic *logRadioCharacteristic = nullptr;
|
|
||||||
BLEServer *bleServer = nullptr;
|
|
||||||
|
|
||||||
static bool passkeyShowing = false;
|
|
||||||
std::atomic<uint16_t> hostedConnHandle{BLE_HS_CONN_HANDLE_NONE};
|
|
||||||
|
|
||||||
void clearPairingDisplay()
|
|
||||||
{
|
|
||||||
if (!passkeyShowing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
passkeyShowing = false;
|
|
||||||
#if HAS_SCREEN
|
|
||||||
if (screen) {
|
|
||||||
screen->endAlert();
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
class HostedBluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
HostedBluetoothPhoneAPI() : concurrency::OSThread("HostedBluetooth") { api_type = TYPE_BLE; }
|
|
||||||
|
|
||||||
std::mutex fromPhoneMutex;
|
|
||||||
std::atomic<size_t> fromPhoneQueueSize{0};
|
|
||||||
std::array<BLEValue, kBluetoothFromPhoneQueueSize> fromPhoneQueue{};
|
|
||||||
|
|
||||||
std::mutex toPhoneMutex;
|
|
||||||
std::atomic<size_t> toPhoneQueueSize{0};
|
|
||||||
std::array<std::array<uint8_t, meshtastic_FromRadio_size>, kBluetoothToPhoneQueueSize> toPhoneQueue{};
|
|
||||||
std::array<size_t, kBluetoothToPhoneQueueSize> toPhoneQueueByteSizes{};
|
|
||||||
std::atomic<bool> onReadCallbackIsWaitingForData{false};
|
|
||||||
|
|
||||||
protected:
|
|
||||||
int32_t runOnce() override
|
|
||||||
{
|
|
||||||
while (fromPhoneQueueSize > 0 || onReadCallbackIsWaitingForData) {
|
|
||||||
runOnceHandleFromPhoneQueue();
|
|
||||||
runOnceHandleToPhoneQueue();
|
|
||||||
}
|
|
||||||
return INT32_MAX;
|
|
||||||
}
|
|
||||||
|
|
||||||
void onNowHasData(uint32_t fromRadioNum) override
|
|
||||||
{
|
|
||||||
PhoneAPI::onNowHasData(fromRadioNum);
|
|
||||||
|
|
||||||
if (!fromNumCharacteristic || !bleServer || bleServer->getConnectedCount() == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t val[4];
|
|
||||||
put_le32(val, fromRadioNum);
|
|
||||||
fromNumCharacteristic->setValue(val, sizeof(val));
|
|
||||||
fromNumCharacteristic->notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool checkIsConnected() override { return bleServer && bleServer->getConnectedCount() > 0; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
void runOnceHandleToPhoneQueue()
|
|
||||||
{
|
|
||||||
if (!onReadCallbackIsWaitingForData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t fromRadioBytes[meshtastic_FromRadio_size] = {0};
|
|
||||||
size_t numBytes = getFromRadio(fromRadioBytes);
|
|
||||||
|
|
||||||
if (numBytes > 0 && toPhoneQueueSize < kBluetoothToPhoneQueueSize) {
|
|
||||||
std::lock_guard<std::mutex> guard(toPhoneMutex);
|
|
||||||
const size_t storeAtIndex = toPhoneQueueSize.load();
|
|
||||||
memcpy(toPhoneQueue[storeAtIndex].data(), fromRadioBytes, numBytes);
|
|
||||||
toPhoneQueueByteSizes[storeAtIndex] = numBytes;
|
|
||||||
toPhoneQueueSize++;
|
|
||||||
}
|
|
||||||
|
|
||||||
onReadCallbackIsWaitingForData = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void runOnceHandleFromPhoneQueue()
|
|
||||||
{
|
|
||||||
if (fromPhoneQueueSize == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BLEValue val;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(fromPhoneMutex);
|
|
||||||
val = fromPhoneQueue[0];
|
|
||||||
for (size_t i = 1; i < fromPhoneQueueSize; ++i) {
|
|
||||||
fromPhoneQueue[i - 1] = fromPhoneQueue[i];
|
|
||||||
}
|
|
||||||
fromPhoneQueueSize--;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToRadio(val.getData(), val.getLength());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static HostedBluetoothPhoneAPI *bluetoothPhoneAPI = nullptr;
|
|
||||||
uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE] = {0};
|
|
||||||
|
|
||||||
class HostedBluetoothToRadioCallback : public BLECharacteristicCallbacks
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
void onWrite(BLECharacteristic *pCharacteristic) override
|
|
||||||
{
|
|
||||||
if (!bluetoothPhoneAPI) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BLEValue val;
|
|
||||||
val.setValue(pCharacteristic->getData(), pCharacteristic->getLength());
|
|
||||||
|
|
||||||
if (memcmp(lastToRadio, val.getData(), val.getLength()) == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bluetoothPhoneAPI->fromPhoneQueueSize >= kBluetoothFromPhoneQueueSize) {
|
|
||||||
LOG_WARN("Hosted BLE onWrite drop: queue full (%u bytes)", val.getLength());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
memcpy(lastToRadio, val.getData(), val.getLength());
|
|
||||||
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(bluetoothPhoneAPI->fromPhoneMutex);
|
|
||||||
bluetoothPhoneAPI->fromPhoneQueue.at(bluetoothPhoneAPI->fromPhoneQueueSize) = val;
|
|
||||||
bluetoothPhoneAPI->fromPhoneQueueSize++;
|
|
||||||
}
|
|
||||||
|
|
||||||
bluetoothPhoneAPI->setIntervalFromNow(0);
|
|
||||||
concurrency::mainDelay.interrupt();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class HostedBluetoothFromRadioCallback : public BLECharacteristicCallbacks
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
void onRead(BLECharacteristic *pCharacteristic) override
|
|
||||||
{
|
|
||||||
if (!bluetoothPhoneAPI) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bluetoothPhoneAPI->toPhoneQueueSize == 0) {
|
|
||||||
bluetoothPhoneAPI->onReadCallbackIsWaitingForData = true;
|
|
||||||
bluetoothPhoneAPI->setIntervalFromNow(0);
|
|
||||||
concurrency::mainDelay.interrupt();
|
|
||||||
|
|
||||||
int tries = 0;
|
|
||||||
while (bluetoothPhoneAPI->onReadCallbackIsWaitingForData && tries < 4000) {
|
|
||||||
delay(tries < 20 ? 1 : 5);
|
|
||||||
tries++;
|
|
||||||
if (tries == 4000) {
|
|
||||||
LOG_WARN("BLE onRead: timeout waiting for data after %d tries, giving up and returning 0-size response",
|
|
||||||
tries);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t fromRadioBytes[meshtastic_FromRadio_size] = {0};
|
|
||||||
size_t numBytes = 0;
|
|
||||||
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(bluetoothPhoneAPI->toPhoneMutex);
|
|
||||||
size_t pending = bluetoothPhoneAPI->toPhoneQueueSize.load();
|
|
||||||
if (pending > 0) {
|
|
||||||
numBytes = bluetoothPhoneAPI->toPhoneQueueByteSizes[0];
|
|
||||||
memcpy(fromRadioBytes, bluetoothPhoneAPI->toPhoneQueue[0].data(), numBytes);
|
|
||||||
|
|
||||||
for (size_t i = 1; i < pending; ++i) {
|
|
||||||
memcpy(bluetoothPhoneAPI->toPhoneQueue[i - 1].data(), bluetoothPhoneAPI->toPhoneQueue[i].data(),
|
|
||||||
bluetoothPhoneAPI->toPhoneQueueByteSizes[i]);
|
|
||||||
bluetoothPhoneAPI->toPhoneQueueByteSizes[i - 1] = bluetoothPhoneAPI->toPhoneQueueByteSizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
bluetoothPhoneAPI->toPhoneQueueSize--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pCharacteristic->setValue(fromRadioBytes, numBytes);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class HostedBluetoothServerCallback : public BLEServerCallbacks
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit HostedBluetoothServerCallback(HostedBluetooth *owner) : owner(owner) {}
|
|
||||||
|
|
||||||
private:
|
|
||||||
HostedBluetooth *owner;
|
|
||||||
|
|
||||||
void onConnect(BLEServer *pServer, struct ble_gap_conn_desc *desc) override
|
|
||||||
{
|
|
||||||
BLEAddress peerAddr(desc->peer_id_addr);
|
|
||||||
LOG_INFO("Hosted BLE incoming connection %s", peerAddr.toString().c_str());
|
|
||||||
owner->setConnected(true);
|
|
||||||
hostedConnHandle = desc->conn_handle;
|
|
||||||
|
|
||||||
const int dataLenResult = ble_gap_set_data_len(desc->conn_handle, kPreferredBleTxOctets, kPreferredBleTxTimeUs);
|
|
||||||
if (dataLenResult != 0) {
|
|
||||||
LOG_WARN("Hosted BLE failed to raise data length rc=%d", dataLenResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
pServer->updateConnParams(desc->conn_handle, 6, 12, 0, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
void onDisconnect(BLEServer *pServer, struct ble_gap_conn_desc *desc) override
|
|
||||||
{
|
|
||||||
(void)pServer;
|
|
||||||
(void)desc;
|
|
||||||
LOG_INFO("Hosted BLE disconnected");
|
|
||||||
owner->setConnected(false);
|
|
||||||
meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED);
|
|
||||||
bluetoothStatus->updateStatus(&newStatus);
|
|
||||||
clearPairingDisplay();
|
|
||||||
hostedConnHandle = BLE_HS_CONN_HANDLE_NONE;
|
|
||||||
memset(lastToRadio, 0, sizeof(lastToRadio));
|
|
||||||
|
|
||||||
if (bluetoothPhoneAPI) {
|
|
||||||
bluetoothPhoneAPI->close();
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(bluetoothPhoneAPI->fromPhoneMutex);
|
|
||||||
bluetoothPhoneAPI->fromPhoneQueueSize = 0;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(bluetoothPhoneAPI->toPhoneMutex);
|
|
||||||
bluetoothPhoneAPI->toPhoneQueueSize = 0;
|
|
||||||
}
|
|
||||||
bluetoothPhoneAPI->onReadCallbackIsWaitingForData = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
owner->startAdvertising();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class HostedBluetoothSecurityCallback : public BLESecurityCallbacks
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
void onPassKeyNotify(uint32_t passkey) override
|
|
||||||
{
|
|
||||||
LOG_INFO("*** Enter passkey %06u on the peer side ***", passkey);
|
|
||||||
powerFSM.trigger(EVENT_BLUETOOTH_PAIR);
|
|
||||||
|
|
||||||
meshtastic::BluetoothStatus newStatus(std::to_string(passkey));
|
|
||||||
bluetoothStatus->updateStatus(&newStatus);
|
|
||||||
|
|
||||||
#if HAS_SCREEN
|
|
||||||
if (screen) {
|
|
||||||
screen->startAlert([passkey](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void {
|
|
||||||
char btPIN[16] = "888888";
|
|
||||||
snprintf(btPIN, sizeof(btPIN), "%06u", passkey);
|
|
||||||
int x_offset = display->width() / 2;
|
|
||||||
int y_offset = display->height() <= 80 ? 0 : 12;
|
|
||||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
|
||||||
display->setFont(FONT_MEDIUM);
|
|
||||||
display->drawString(x_offset + x, y_offset + y, "Bluetooth");
|
|
||||||
#if !defined(M5STACK_UNITC6L)
|
|
||||||
display->setFont(FONT_SMALL);
|
|
||||||
y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5;
|
|
||||||
display->drawString(x_offset + x, y_offset + y, "Enter this code");
|
|
||||||
#endif
|
|
||||||
display->setFont(FONT_LARGE);
|
|
||||||
char pin[8];
|
|
||||||
snprintf(pin, sizeof(pin), "%.3s %.3s", btPIN, btPIN + 3);
|
|
||||||
y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_SMALL - 5 : y_offset + FONT_HEIGHT_SMALL + 5;
|
|
||||||
display->drawString(x_offset + x, y_offset + y, pin);
|
|
||||||
|
|
||||||
display->setFont(FONT_SMALL);
|
|
||||||
char deviceName[64];
|
|
||||||
snprintf(deviceName, sizeof(deviceName), "Name: %s", getDeviceName());
|
|
||||||
y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_LARGE - 6 : y_offset + FONT_HEIGHT_LARGE + 5;
|
|
||||||
display->drawString(x_offset + x, y_offset + y, deviceName);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
passkeyShowing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void onAuthenticationComplete(ble_gap_conn_desc *desc) override
|
|
||||||
{
|
|
||||||
(void)desc;
|
|
||||||
LOG_INFO("Hosted BLE authentication complete");
|
|
||||||
|
|
||||||
meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED);
|
|
||||||
bluetoothStatus->updateStatus(&newStatus);
|
|
||||||
clearPairingDisplay();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
HostedBluetoothToRadioCallback *toRadioCallbacks = nullptr;
|
|
||||||
HostedBluetoothFromRadioCallback *fromRadioCallbacks = nullptr;
|
|
||||||
HostedBluetoothSecurityCallback *securityCallbacks = nullptr;
|
|
||||||
|
|
||||||
gpio_num_t getSlaveResetGpio()
|
|
||||||
{
|
|
||||||
#if defined(CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE)
|
|
||||||
return static_cast<gpio_num_t>(CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE);
|
|
||||||
#elif defined(CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE)
|
|
||||||
return static_cast<gpio_num_t>(CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE);
|
|
||||||
#else
|
|
||||||
return GPIO_NUM_NC;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void setSlaveResetLine(bool assertReset)
|
|
||||||
{
|
|
||||||
const gpio_num_t gpioNum = getSlaveResetGpio();
|
|
||||||
if (gpioNum == GPIO_NUM_NC || gpioNum < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gpio_reset_pin(gpioNum) != ESP_OK) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (gpio_set_direction(gpioNum, GPIO_MODE_OUTPUT) != ESP_OK) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int activeLevel =
|
|
||||||
#if defined(CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH)
|
|
||||||
1;
|
|
||||||
#else
|
|
||||||
0;
|
|
||||||
#endif
|
|
||||||
const int inactiveLevel = activeLevel ? 0 : 1;
|
|
||||||
|
|
||||||
gpio_set_level(gpioNum, assertReset ? activeLevel : inactiveLevel);
|
|
||||||
LOG_DEBUG("[HostedBluetooth] setSlaveResetLine: GPIO[%d] -> %d (assertReset=%d, activeLevel=%d)", gpioNum,
|
|
||||||
assertReset ? activeLevel : inactiveLevel, assertReset, activeLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
void hostedEventHandler(void *arg, esp_event_base_t eventBase, int32_t eventId, void *eventData)
|
|
||||||
{
|
|
||||||
(void)eventData;
|
|
||||||
|
|
||||||
auto *self = static_cast<HostedBluetooth *>(arg);
|
|
||||||
if (!self || eventBase != ESP_HOSTED_EVENT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (eventId) {
|
|
||||||
case ESP_HOSTED_EVENT_TRANSPORT_UP:
|
|
||||||
LOG_INFO("ESP-Hosted transport is up");
|
|
||||||
self->setConnected(true);
|
|
||||||
break;
|
|
||||||
case ESP_HOSTED_EVENT_TRANSPORT_DOWN:
|
|
||||||
LOG_WARN("ESP-Hosted transport is down");
|
|
||||||
[[fallthrough]];
|
|
||||||
case ESP_HOSTED_EVENT_TRANSPORT_FAILURE:
|
|
||||||
if (eventId == ESP_HOSTED_EVENT_TRANSPORT_FAILURE) {
|
|
||||||
LOG_ERROR("ESP-Hosted transport failure");
|
|
||||||
}
|
|
||||||
self->setConnected(false);
|
|
||||||
break;
|
|
||||||
case ESP_HOSTED_EVENT_CP_INIT:
|
|
||||||
case ESP_HOSTED_EVENT_CP_HEARTBEAT:
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
HostedBluetooth::HostedBluetooth() {}
|
|
||||||
|
|
||||||
HostedBluetooth::~HostedBluetooth()
|
|
||||||
{
|
|
||||||
deinit();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool HostedBluetooth::registerCallbacks()
|
|
||||||
{
|
|
||||||
if (callbacksRegistered) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defensive cleanup in case setup() is called again after an incomplete/early previous init.
|
|
||||||
// esp_event_handler_unregister can safely fail if the handler wasn't present.
|
|
||||||
esp_event_handler_unregister(ESP_HOSTED_EVENT, ESP_EVENT_ANY_ID, hostedEventHandler);
|
|
||||||
|
|
||||||
esp_err_t err = esp_event_handler_register(ESP_HOSTED_EVENT, ESP_EVENT_ANY_ID, hostedEventHandler, this);
|
|
||||||
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
|
||||||
LOG_ERROR("Failed to register hosted event handler: %s", esp_err_to_name(err));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (err == ESP_ERR_INVALID_STATE) {
|
|
||||||
LOG_WARN("Hosted event handler already registered, continuing");
|
|
||||||
}
|
|
||||||
|
|
||||||
callbacksRegistered = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::unregisterCallbacks()
|
|
||||||
{
|
|
||||||
if (!callbacksRegistered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
esp_event_handler_unregister(ESP_HOSTED_EVENT, ESP_EVENT_ANY_ID, hostedEventHandler);
|
|
||||||
|
|
||||||
callbacksRegistered = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::setup()
|
|
||||||
{
|
|
||||||
if (active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure co-processor is released from reset before bringing transport back up.
|
|
||||||
setSlaveResetLine(false);
|
|
||||||
LOG_DEBUG("[HostedBluetooth] setup(): Released co-processor from reset");
|
|
||||||
|
|
||||||
if (!registerCallbacks()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
active = setupGatt();
|
|
||||||
firstRssiLogged.store(false);
|
|
||||||
if (active) {
|
|
||||||
LOG_INFO("ESP-Hosted Bluetooth ready");
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Hosted BLE setup failed in hosted mode");
|
|
||||||
deinit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::shutdown()
|
|
||||||
{
|
|
||||||
deinit();
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::deinit()
|
|
||||||
{
|
|
||||||
if (!active && !callbacksRegistered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdownGatt();
|
|
||||||
if (BLEDevice::getInitialized()) {
|
|
||||||
BLEDevice::deinit(false);
|
|
||||||
} else {
|
|
||||||
hostedDeinitBLE();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hold co-processor in reset when hosted transport is disabled to reduce idle draw.
|
|
||||||
setSlaveResetLine(true);
|
|
||||||
|
|
||||||
unregisterCallbacks();
|
|
||||||
|
|
||||||
connected.store(false);
|
|
||||||
active = false;
|
|
||||||
rssi.store(0);
|
|
||||||
firstRssiLogged.store(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::clearBonds()
|
|
||||||
{
|
|
||||||
ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_PEER_SEC, nullptr);
|
|
||||||
ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_CCCD, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool HostedBluetooth::isActive()
|
|
||||||
{
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool HostedBluetooth::isConnected()
|
|
||||||
{
|
|
||||||
if (!connected.load()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return bleServer && bleServer->getConnectedCount() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int HostedBluetooth::getRssi()
|
|
||||||
{
|
|
||||||
if (!bleServer || !isConnected()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t connHandle = hostedConnHandle.load();
|
|
||||||
if (connHandle == BLE_HS_CONN_HANDLE_NONE) {
|
|
||||||
const auto peers = bleServer->getPeerDevices(true);
|
|
||||||
if (!peers.empty()) {
|
|
||||||
connHandle = peers.begin()->first;
|
|
||||||
hostedConnHandle = connHandle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (connHandle == BLE_HS_CONN_HANDLE_NONE) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int8_t currentRssi = 0;
|
|
||||||
const int rc = ble_gap_conn_rssi(connHandle, ¤tRssi);
|
|
||||||
if (rc == 0) {
|
|
||||||
setRssi(currentRssi);
|
|
||||||
return currentRssi;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rssi.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::sendLog(const uint8_t *logMessage, size_t length)
|
|
||||||
{
|
|
||||||
if (!logRadioCharacteristic || !isConnected() || length > 512) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logRadioCharacteristic->setValue(logMessage, length);
|
|
||||||
logRadioCharacteristic->notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::setConnected(bool value)
|
|
||||||
{
|
|
||||||
connected.store(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::setRssi(int value)
|
|
||||||
{
|
|
||||||
rssi.store(value);
|
|
||||||
maybeLogFirstRssi(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::maybeLogFirstRssi(int value)
|
|
||||||
{
|
|
||||||
bool expected = false;
|
|
||||||
if (firstRssiLogged.compare_exchange_strong(expected, true)) {
|
|
||||||
LOG_INFO("ESP-Hosted first RSSI update: %d dBm", value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool HostedBluetooth::setupGatt()
|
|
||||||
{
|
|
||||||
memset(lastToRadio, 0, sizeof(lastToRadio));
|
|
||||||
hostedConnHandle = BLE_HS_CONN_HANDLE_NONE;
|
|
||||||
|
|
||||||
if (!BLEDevice::init(getDeviceName())) {
|
|
||||||
LOG_ERROR("Hosted BLE init failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
#ifdef ESP_PWR_LVL_P9
|
|
||||||
BLEDevice::setPower(ESP_PWR_LVL_P9);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
BLESecurity *security = new BLESecurity();
|
|
||||||
security->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
|
|
||||||
security->setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
|
|
||||||
if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) {
|
|
||||||
security->setCapability(ESP_IO_CAP_OUT);
|
|
||||||
if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN) {
|
|
||||||
LOG_INFO("Hosted BLE using random passkey");
|
|
||||||
security->setPassKey(false);
|
|
||||||
} else {
|
|
||||||
LOG_INFO("Hosted BLE using fixed passkey");
|
|
||||||
security->setPassKey(true, config.bluetooth.fixed_pin);
|
|
||||||
}
|
|
||||||
// Enable authorization requirements:
|
|
||||||
// - bonding: true (for persistent storage of the keys)
|
|
||||||
// - MITM: true (enables Man-In-The-Middle protection for password prompts)
|
|
||||||
// - secure connection: true (enables secure connection for encryption)
|
|
||||||
security->setAuthenticationMode(true, true, true);
|
|
||||||
} else {
|
|
||||||
security->setCapability(ESP_IO_CAP_NONE);
|
|
||||||
security->setAuthenticationMode(true, false, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!securityCallbacks) {
|
|
||||||
securityCallbacks = new HostedBluetoothSecurityCallback();
|
|
||||||
}
|
|
||||||
BLEDevice::setSecurityCallbacks(securityCallbacks);
|
|
||||||
|
|
||||||
const int mtuResult = BLEDevice::setMTU(kPreferredBleMtu);
|
|
||||||
if (mtuResult == 0) {
|
|
||||||
LOG_INFO("Hosted BLE MTU request set to %u", kPreferredBleMtu);
|
|
||||||
} else {
|
|
||||||
LOG_WARN("Hosted BLE unable to request MTU %u, rc=%d", kPreferredBleMtu, mtuResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
bleServer = BLEDevice::createServer();
|
|
||||||
if (!bleServer) {
|
|
||||||
LOG_ERROR("Hosted BLE createServer failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int nameRc = ble_svc_gap_device_name_set(BLEDevice::getDeviceName().c_str());
|
|
||||||
if (nameRc != 0) {
|
|
||||||
LOG_WARN("Hosted BLE device_name_set rc=%d %s", nameRc, BLEUtils::returnCodeToString(nameRc));
|
|
||||||
}
|
|
||||||
|
|
||||||
bleServer->setCallbacks(new HostedBluetoothServerCallback(this));
|
|
||||||
|
|
||||||
BLEService *meshService = bleServer->createService(MESH_SERVICE_UUID);
|
|
||||||
if (!meshService) {
|
|
||||||
LOG_ERROR("Hosted BLE mesh service creation failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
BLECharacteristic *toRadioCharacteristic = nullptr;
|
|
||||||
BLECharacteristic *fromRadioCharacteristic = nullptr;
|
|
||||||
if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) {
|
|
||||||
toRadioCharacteristic = meshService->createCharacteristic(TORADIO_UUID, BLECharacteristic::PROPERTY_WRITE);
|
|
||||||
fromRadioCharacteristic = meshService->createCharacteristic(FROMRADIO_UUID, BLECharacteristic::PROPERTY_READ);
|
|
||||||
fromNumCharacteristic = meshService->createCharacteristic(FROMNUM_UUID, BLECharacteristic::PROPERTY_NOTIFY |
|
|
||||||
BLECharacteristic::PROPERTY_READ);
|
|
||||||
logRadioCharacteristic = meshService->createCharacteristic(LOGRADIO_UUID, BLECharacteristic::PROPERTY_NOTIFY |
|
|
||||||
BLECharacteristic::PROPERTY_READ);
|
|
||||||
} else {
|
|
||||||
toRadioCharacteristic = meshService->createCharacteristic(TORADIO_UUID, BLECharacteristic::PROPERTY_WRITE |
|
|
||||||
BLECharacteristic::PROPERTY_WRITE_AUTHEN |
|
|
||||||
BLECharacteristic::PROPERTY_WRITE_ENC);
|
|
||||||
fromRadioCharacteristic = meshService->createCharacteristic(FROMRADIO_UUID, BLECharacteristic::PROPERTY_READ |
|
|
||||||
BLECharacteristic::PROPERTY_READ_AUTHEN |
|
|
||||||
BLECharacteristic::PROPERTY_READ_ENC);
|
|
||||||
fromNumCharacteristic = meshService->createCharacteristic(
|
|
||||||
FROMNUM_UUID, BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_READ |
|
|
||||||
BLECharacteristic::PROPERTY_READ_AUTHEN | BLECharacteristic::PROPERTY_READ_ENC);
|
|
||||||
logRadioCharacteristic = meshService->createCharacteristic(
|
|
||||||
LOGRADIO_UUID, BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_READ |
|
|
||||||
BLECharacteristic::PROPERTY_READ_AUTHEN | BLECharacteristic::PROPERTY_READ_ENC);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!toRadioCharacteristic || !fromRadioCharacteristic || !fromNumCharacteristic || !logRadioCharacteristic) {
|
|
||||||
LOG_ERROR("Hosted BLE characteristic creation failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bluetoothPhoneAPI) {
|
|
||||||
bluetoothPhoneAPI = new HostedBluetoothPhoneAPI();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!toRadioCallbacks) {
|
|
||||||
toRadioCallbacks = new HostedBluetoothToRadioCallback();
|
|
||||||
}
|
|
||||||
if (!fromRadioCallbacks) {
|
|
||||||
fromRadioCallbacks = new HostedBluetoothFromRadioCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
toRadioCharacteristic->setCallbacks(toRadioCallbacks);
|
|
||||||
fromRadioCharacteristic->setCallbacks(fromRadioCallbacks);
|
|
||||||
meshService->start();
|
|
||||||
|
|
||||||
startAdvertising();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::shutdownGatt()
|
|
||||||
{
|
|
||||||
if (bluetoothPhoneAPI) {
|
|
||||||
bluetoothPhoneAPI->close();
|
|
||||||
delete bluetoothPhoneAPI;
|
|
||||||
bluetoothPhoneAPI = nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete toRadioCallbacks;
|
|
||||||
toRadioCallbacks = nullptr;
|
|
||||||
|
|
||||||
delete fromRadioCallbacks;
|
|
||||||
fromRadioCallbacks = nullptr;
|
|
||||||
|
|
||||||
delete securityCallbacks;
|
|
||||||
securityCallbacks = nullptr;
|
|
||||||
|
|
||||||
if (bleServer) {
|
|
||||||
BLEAdvertising *advertising = BLEDevice::getAdvertising();
|
|
||||||
if (advertising) {
|
|
||||||
advertising->stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fromNumCharacteristic = nullptr;
|
|
||||||
logRadioCharacteristic = nullptr;
|
|
||||||
bleServer = nullptr;
|
|
||||||
hostedConnHandle = BLE_HS_CONN_HANDLE_NONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
void HostedBluetooth::startAdvertising()
|
|
||||||
{
|
|
||||||
BLEAdvertising *advertising = BLEDevice::getAdvertising();
|
|
||||||
if (!advertising) {
|
|
||||||
LOG_ERROR("Hosted BLE getAdvertising failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
advertising->stop();
|
|
||||||
advertising->reset();
|
|
||||||
advertising->addServiceUUID(MESH_SERVICE_UUID);
|
|
||||||
|
|
||||||
BLEAdvertisementData scan;
|
|
||||||
scan.setName(getDeviceName());
|
|
||||||
advertising->setScanResponseData(scan);
|
|
||||||
advertising->setMinPreferred(0x06);
|
|
||||||
advertising->setMaxPreferred(0x12);
|
|
||||||
|
|
||||||
if (!advertising->start(0)) {
|
|
||||||
LOG_ERROR("Hosted BLE failed to start advertising");
|
|
||||||
} else {
|
|
||||||
LOG_INFO("Hosted BLE advertising started");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "BluetoothCommon.h"
|
|
||||||
#include <atomic>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder backend for ESP32-P4 + ESP-Hosted BLE transport.
|
|
||||||
*
|
|
||||||
* This intentionally mirrors the NimbleBluetooth surface used by the firmware,
|
|
||||||
* so the caller side can stay stable while we implement esp-hosted internals.
|
|
||||||
*/
|
|
||||||
class HostedBluetooth : public BluetoothApi
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
HostedBluetooth();
|
|
||||||
~HostedBluetooth() override;
|
|
||||||
|
|
||||||
void setup() override;
|
|
||||||
void shutdown() override;
|
|
||||||
void deinit() override;
|
|
||||||
void clearBonds() override;
|
|
||||||
bool isActive() override;
|
|
||||||
bool isConnected() override;
|
|
||||||
int getRssi() override;
|
|
||||||
void sendLog(const uint8_t *logMessage, size_t length) override;
|
|
||||||
void startAdvertising();
|
|
||||||
|
|
||||||
void setConnected(bool value);
|
|
||||||
void setRssi(int value);
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool setupGatt();
|
|
||||||
void shutdownGatt();
|
|
||||||
void maybeLogFirstRssi(int value);
|
|
||||||
bool registerCallbacks();
|
|
||||||
void unregisterCallbacks();
|
|
||||||
|
|
||||||
bool active = false;
|
|
||||||
std::atomic<bool> connected{false};
|
|
||||||
std::atomic<int> rssi{0};
|
|
||||||
std::atomic<bool> firstRssiLogged{false};
|
|
||||||
bool callbacksRegistered = false;
|
|
||||||
};
|
|
||||||
@@ -14,6 +14,11 @@ Lock::Lock() : handle(xSemaphoreCreateBinary())
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Lock::~Lock()
|
||||||
|
{
|
||||||
|
vSemaphoreDelete(handle);
|
||||||
|
}
|
||||||
|
|
||||||
void Lock::lock()
|
void Lock::lock()
|
||||||
{
|
{
|
||||||
if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
|
if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
|
||||||
@@ -30,6 +35,8 @@ void Lock::unlock()
|
|||||||
#else
|
#else
|
||||||
Lock::Lock() {}
|
Lock::Lock() {}
|
||||||
|
|
||||||
|
Lock::~Lock() {}
|
||||||
|
|
||||||
void Lock::lock() {}
|
void Lock::lock() {}
|
||||||
|
|
||||||
void Lock::unlock() {}
|
void Lock::unlock() {}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class Lock
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
Lock();
|
Lock();
|
||||||
|
~Lock();
|
||||||
|
|
||||||
Lock(const Lock &) = delete;
|
Lock(const Lock &) = delete;
|
||||||
Lock &operator=(const Lock &) = delete;
|
Lock &operator=(const Lock &) = delete;
|
||||||
|
|||||||
+3
-10
@@ -78,11 +78,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
// Configuration
|
// 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
|
/// Convert a preprocessor name into a quoted string
|
||||||
#define xstr(s) ystr(s)
|
#define xstr(s) ystr(s)
|
||||||
#define ystr(s) #s
|
#define ystr(s) #s
|
||||||
@@ -234,7 +229,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#define BME_ADDR 0x76
|
#define BME_ADDR 0x76
|
||||||
#define BME_ADDR_ALTERNATE 0x77
|
#define BME_ADDR_ALTERNATE 0x77
|
||||||
#define MCP9808_ADDR 0x18
|
#define MCP9808_ADDR 0x18
|
||||||
#define INA_ADDR 0x40 // same as SHT2X
|
#define INA_ADDR 0x40
|
||||||
#define INA_ADDR_ALTERNATE 0x41
|
#define INA_ADDR_ALTERNATE 0x41
|
||||||
#define INA_ADDR_WAVESHARE_UPS 0x43
|
#define INA_ADDR_WAVESHARE_UPS 0x43
|
||||||
#define INA3221_ADDR 0x42
|
#define INA3221_ADDR 0x42
|
||||||
@@ -247,8 +242,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#define LPS22HB_ADDR 0x5C
|
#define LPS22HB_ADDR 0x5C
|
||||||
#define LPS22HB_ADDR_ALT 0x5D
|
#define LPS22HB_ADDR_ALT 0x5D
|
||||||
#define SFA30_ADDR 0x5D
|
#define SFA30_ADDR 0x5D
|
||||||
#define SHTXX_ADDR 0x44
|
#define SHT31_4x_ADDR 0x44
|
||||||
#define SHTXX_ADDR_ALT 0x45
|
#define SHT31_4x_ADDR_ALT 0x45
|
||||||
#define PMSA003I_ADDR 0x12
|
#define PMSA003I_ADDR 0x12
|
||||||
#define QMA6100P_ADDR 0x12
|
#define QMA6100P_ADDR 0x12
|
||||||
#define AHT10_ADDR 0x38
|
#define AHT10_ADDR 0x38
|
||||||
@@ -502,7 +497,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#define MESHTASTIC_EXCLUDE_PKI 1
|
#define MESHTASTIC_EXCLUDE_PKI 1
|
||||||
#define MESHTASTIC_EXCLUDE_POWER_FSM 1
|
#define MESHTASTIC_EXCLUDE_POWER_FSM 1
|
||||||
#define MESHTASTIC_EXCLUDE_TZ 1
|
#define MESHTASTIC_EXCLUDE_TZ 1
|
||||||
#define MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH 1
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Turn off all optional modules
|
// Turn off all optional modules
|
||||||
@@ -519,7 +513,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
|
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
|
||||||
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
|
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
|
||||||
#define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1
|
#define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1
|
||||||
#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1
|
|
||||||
#define MESHTASTIC_EXCLUDE_ATAK 1
|
#define MESHTASTIC_EXCLUDE_ATAK 1
|
||||||
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
|
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
|
||||||
#define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1
|
#define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1
|
||||||
|
|||||||
@@ -136,9 +136,7 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
|
|||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
/// for SEN5X detection
|
||||||
// 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
|
// 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
|
// for the screen at high speed. The speed needs to be at 100kHz, otherwise
|
||||||
// detection will not work
|
// detection will not work
|
||||||
@@ -176,46 +174,6 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address)
|
|||||||
|
|
||||||
return String(productName);
|
return String(productName);
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
|
||||||
bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
|
||||||
{
|
|
||||||
|
|
||||||
i2cBus->beginTransmission(address);
|
|
||||||
i2cBus->write(0xFA);
|
|
||||||
i2cBus->write(0x0F);
|
|
||||||
|
|
||||||
if (i2cBus->endTransmission() != 0)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (i2cBus->requestFrom(address, (uint8_t)8) != 8)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Just flush the data
|
|
||||||
while (i2cBus->available() < 8) {
|
|
||||||
i2cBus->read();
|
|
||||||
}
|
|
||||||
|
|
||||||
i2cBus->beginTransmission(address);
|
|
||||||
i2cBus->write(0xFC);
|
|
||||||
i2cBus->write(0xC9);
|
|
||||||
|
|
||||||
if (i2cBus->endTransmission() != 0)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (i2cBus->requestFrom(address, (uint8_t)6) != 6)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Just flush the data
|
|
||||||
while (i2cBus->available() < 6) {
|
|
||||||
i2cBus->read();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assume we detect the SHT21 if something came back from the request
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
||||||
case ADDR: \
|
case ADDR: \
|
||||||
@@ -413,47 +371,27 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
|||||||
break;
|
break;
|
||||||
#endif
|
#endif
|
||||||
#if !defined(M5STACK_UNITC6L)
|
#if !defined(M5STACK_UNITC6L)
|
||||||
case INA_ADDR: // Same as SHT2X
|
case INA_ADDR:
|
||||||
case INA_ADDR_ALTERNATE:
|
case INA_ADDR_ALTERNATE:
|
||||||
case INA_ADDR_WAVESHARE_UPS: {
|
case INA_ADDR_WAVESHARE_UPS:
|
||||||
uint16_t mfg = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
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);
|
||||||
|
|
||||||
LOG_DEBUG("Register MFG_UID: 0x%x", mfg);
|
if (registerValue == 0x2260) {
|
||||||
|
|
||||||
// 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);
|
logFoundDevice("INA226", (uint8_t)addr.address);
|
||||||
type = INA226;
|
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);
|
logFoundDevice("INA260", (uint8_t)addr.address);
|
||||||
type = INA260;
|
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);
|
logFoundDevice("INA219", (uint8_t)addr.address);
|
||||||
type = INA219;
|
type = INA219;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
case INA3221_ADDR:
|
case INA3221_ADDR:
|
||||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||||
LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue);
|
LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue);
|
||||||
@@ -510,19 +448,22 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SHTXX_ADDR: // same as OPT3001_ADDR_ALT
|
case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT
|
||||||
case SHTXX_ADDR_ALT: // same as OPT3001_ADDR
|
case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR
|
||||||
if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
|
if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
|
||||||
type = OPT3001;
|
type = OPT3001;
|
||||||
logFoundDevice("OPT3001", (uint8_t)addr.address);
|
logFoundDevice("OPT3001", (uint8_t)addr.address);
|
||||||
} else { // SHTXX
|
} else if (i2cCommandResponseLength(addr, 0x89, 6)) { // SHT4x serial number (6 bytes inc. CRC)
|
||||||
type = SHTXX;
|
type = SHT4X;
|
||||||
logFoundDevice("SHTXX", (uint8_t)addr.address);
|
logFoundDevice("SHT4X", (uint8_t)addr.address);
|
||||||
|
} else {
|
||||||
|
type = SHT31;
|
||||||
|
logFoundDevice("SHT31", (uint8_t)addr.address);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address)
|
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address)
|
||||||
case RCWL9620_ADDR:
|
case RCWL9620_ADDR:
|
||||||
// get MAX30102 PARTID
|
// get MAX30102 PARTID
|
||||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1);
|
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1);
|
||||||
@@ -758,7 +699,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
|||||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
|
||||||
String prod = "";
|
String prod = "";
|
||||||
prod = readSEN5xProductName(i2cBus, addr.address);
|
prod = readSEN5xProductName(i2cBus, addr.address);
|
||||||
if (prod.startsWith("SEN55")) {
|
if (prod.startsWith("SEN55")) {
|
||||||
@@ -774,7 +714,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
|||||||
logFoundDevice("Sensirion SEN50", addr.address);
|
logFoundDevice("Sensirion SEN50", addr.address);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
if (addr.address == BMX160_ADDR) {
|
if (addr.address == BMX160_ADDR) {
|
||||||
type = BMX160;
|
type = BMX160;
|
||||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||||
|
|||||||
+11
-11
@@ -103,14 +103,6 @@ static int32_t gpsSwitch()
|
|||||||
if (gps) {
|
if (gps) {
|
||||||
int currentState = digitalRead(PIN_GPS_SWITCH);
|
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 the switch is set to zero, disable the GPS Thread
|
||||||
if (firstrun)
|
if (firstrun)
|
||||||
if (currentState == LOW)
|
if (currentState == LOW)
|
||||||
@@ -1025,10 +1017,13 @@ void GPS::up()
|
|||||||
setPowerState(GPS_ACTIVE);
|
setPowerState(GPS_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We've got a GPS lock. Enter a low power state, potentially.
|
// We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially.
|
||||||
void GPS::down()
|
void GPS::down()
|
||||||
{
|
{
|
||||||
scheduling.informGotLock();
|
if (hasValidLocation)
|
||||||
|
scheduling.informGotLock();
|
||||||
|
else
|
||||||
|
scheduling.informSearchFailed();
|
||||||
uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs();
|
uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs();
|
||||||
uint32_t sleepTime = scheduling.msUntilNextSearch();
|
uint32_t sleepTime = scheduling.msUntilNextSearch();
|
||||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
||||||
@@ -1553,7 +1548,12 @@ std::unique_ptr<GPS> GPS::createGps()
|
|||||||
_en_gpio = PIN_GPS_EN;
|
_en_gpio = PIN_GPS_EN;
|
||||||
#endif
|
#endif
|
||||||
#ifdef ARCH_PORTDUINO
|
#ifdef ARCH_PORTDUINO
|
||||||
if (!portduino_config.has_gps)
|
if (portduino_config.has_gps) {
|
||||||
|
// These need to set as flags so later checks will pass on native and GPS will work.
|
||||||
|
// They are not used for any hardware access.
|
||||||
|
_rx_gpio = 1;
|
||||||
|
_tx_gpio = 1;
|
||||||
|
} else
|
||||||
return nullptr;
|
return nullptr;
|
||||||
#endif
|
#endif
|
||||||
if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all
|
if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ void GPSUpdateScheduling::informGotLock()
|
|||||||
searchEndedMs = millis();
|
searchEndedMs = millis();
|
||||||
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
|
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
|
||||||
updateLockTimePrediction();
|
updateLockTimePrediction();
|
||||||
|
consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search finished without obtaining a fix. We still need to mark the end time so
|
||||||
|
// the next sleep is timed correctly, but we must not feed the timeout duration
|
||||||
|
// into predictedMsToGetLock — doing so poisons msUntilNextSearch() and causes
|
||||||
|
// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles.
|
||||||
|
void GPSUpdateScheduling::informSearchFailed()
|
||||||
|
{
|
||||||
|
searchEndedMs = millis();
|
||||||
|
consecutiveFailures++;
|
||||||
|
LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000,
|
||||||
|
consecutiveFailures);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear old lock-time prediction data.
|
// Clear old lock-time prediction data.
|
||||||
@@ -25,6 +38,7 @@ void GPSUpdateScheduling::reset()
|
|||||||
searchEndedMs = 0;
|
searchEndedMs = 0;
|
||||||
searchCount = 0;
|
searchCount = 0;
|
||||||
predictedMsToGetLock = 0;
|
predictedMsToGetLock = 0;
|
||||||
|
consecutiveFailures = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// How many milliseconds before we should next search for GPS position
|
// How many milliseconds before we should next search for GPS position
|
||||||
@@ -36,6 +50,20 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch()
|
|||||||
// Target interval (seconds), between GPS updates
|
// Target interval (seconds), between GPS updates
|
||||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
|
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
|
||||||
|
|
||||||
|
// After a failed search, back off: indoors / no-sky environments will keep failing,
|
||||||
|
// so wake at most once per broadcast interval rather than once per gps_update_interval.
|
||||||
|
// Capped at 1 hour so a user-configured very-long broadcast interval still retries
|
||||||
|
// periodically (in case conditions change). Reset on any successful lock.
|
||||||
|
if (consecutiveFailures > 0) {
|
||||||
|
constexpr uint32_t failureRetryCapMs = 60UL * 60UL * 1000UL; // 1 hour cap
|
||||||
|
uint32_t failureSleepMs =
|
||||||
|
Default::getConfiguredOrDefaultMs(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||||
|
if (failureSleepMs > failureRetryCapMs)
|
||||||
|
failureSleepMs = failureRetryCapMs;
|
||||||
|
if (updateInterval < failureSleepMs)
|
||||||
|
updateInterval = failureSleepMs;
|
||||||
|
}
|
||||||
|
|
||||||
// Check how long until we should start searching, to hopefully hit our target interval
|
// Check how long until we should start searching, to hopefully hit our target interval
|
||||||
uint32_t dueAtMs = searchEndedMs + updateInterval;
|
uint32_t dueAtMs = searchEndedMs + updateInterval;
|
||||||
uint32_t compensatedStart = dueAtMs - predictedMsToGetLock;
|
uint32_t compensatedStart = dueAtMs - predictedMsToGetLock;
|
||||||
@@ -71,14 +99,18 @@ bool GPSUpdateScheduling::isUpdateDue()
|
|||||||
bool GPSUpdateScheduling::searchedTooLong()
|
bool GPSUpdateScheduling::searchedTooLong()
|
||||||
{
|
{
|
||||||
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
|
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
|
||||||
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
||||||
|
constexpr uint32_t postFailureSearchMs = 5UL * oneMinuteMs; // Tighter dwell once we know the environment is hostile
|
||||||
uint32_t elapsed = elapsedSearchMs();
|
uint32_t elapsed = elapsedSearchMs();
|
||||||
|
|
||||||
// Anything over 15 minutes is too long, regardless of the broadcast interval.
|
// Anything over 15 minutes is too long, regardless of the broadcast interval.
|
||||||
// TODO: Make a smarter algorithm that backs off the search dwell time when not getting a lock.
|
|
||||||
if (elapsed > maxSearchClampMs)
|
if (elapsed > maxSearchClampMs)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
// After a prior failed search, shorten the dwell
|
||||||
|
if (consecutiveFailures > 0 && elapsed > postFailureSearchMs)
|
||||||
|
return true;
|
||||||
|
|
||||||
uint32_t minimumOrConfiguredSecs =
|
uint32_t minimumOrConfiguredSecs =
|
||||||
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||||
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
|
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ class GPSUpdateScheduling
|
|||||||
public:
|
public:
|
||||||
// Marks the time of these events, for calculation use
|
// Marks the time of these events, for calculation use
|
||||||
void informSearching();
|
void informSearching();
|
||||||
void informGotLock(); // Predicted lock-time is recalculated here
|
void informGotLock(); // Predicted lock-time is recalculated here
|
||||||
|
void informSearchFailed(); // Search ended without a fix; prediction is left untouched
|
||||||
|
|
||||||
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
|
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
|
||||||
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
|
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
|
||||||
@@ -24,6 +25,7 @@ class GPSUpdateScheduling
|
|||||||
uint32_t searchEndedMs = 0;
|
uint32_t searchEndedMs = 0;
|
||||||
uint32_t searchCount = 0;
|
uint32_t searchCount = 0;
|
||||||
uint32_t predictedMsToGetLock = 0;
|
uint32_t predictedMsToGetLock = 0;
|
||||||
|
uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock
|
||||||
|
|
||||||
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
|
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
|
||||||
};
|
};
|
||||||
+85
-178
@@ -39,7 +39,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#include "draw/NodeListRenderer.h"
|
#include "draw/NodeListRenderer.h"
|
||||||
#include "draw/NotificationRenderer.h"
|
#include "draw/NotificationRenderer.h"
|
||||||
#include "draw/UIRenderer.h"
|
#include "draw/UIRenderer.h"
|
||||||
#include "graphics/TFTColorRegions.h"
|
|
||||||
#include "modules/CannedMessageModule.h"
|
#include "modules/CannedMessageModule.h"
|
||||||
|
|
||||||
#if !MESHTASTIC_EXCLUDE_GPS
|
#if !MESHTASTIC_EXCLUDE_GPS
|
||||||
@@ -55,14 +54,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#include "gps/RTC.h"
|
#include "gps/RTC.h"
|
||||||
#include "graphics/ScreenFonts.h"
|
#include "graphics/ScreenFonts.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/TFTPalette.h"
|
|
||||||
#include "graphics/emotes.h"
|
#include "graphics/emotes.h"
|
||||||
#include "graphics/images.h"
|
#include "graphics/images.h"
|
||||||
#include "input/TouchScreenImpl1.h"
|
#include "input/TouchScreenImpl1.h"
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
#include "mesh-pb-constants.h"
|
#include "mesh-pb-constants.h"
|
||||||
#include "mesh/Channels.h"
|
#include "mesh/Channels.h"
|
||||||
#include "mesh/Default.h"
|
|
||||||
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
||||||
#include "modules/ExternalNotificationModule.h"
|
#include "modules/ExternalNotificationModule.h"
|
||||||
#include "modules/TextMessageModule.h"
|
#include "modules/TextMessageModule.h"
|
||||||
@@ -71,6 +68,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#include "target_specific.h"
|
#include "target_specific.h"
|
||||||
extern MessageStore messageStore;
|
extern MessageStore messageStore;
|
||||||
|
|
||||||
|
#if USE_TFTDISPLAY
|
||||||
|
extern uint16_t TFT_MESH;
|
||||||
|
#else
|
||||||
|
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
||||||
#include "mesh/wifi/WiFiAPClient.h"
|
#include "mesh/wifi/WiFiAPClient.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -95,7 +98,6 @@ namespace graphics
|
|||||||
|
|
||||||
// This means the *visible* area (sh1106 can address 132, but shows 128 for example)
|
// This means the *visible* area (sh1106 can address 132, but shows 128 for example)
|
||||||
#define IDLE_FRAMERATE 1 // in fps
|
#define IDLE_FRAMERATE 1 // in fps
|
||||||
#define COMPASS_ACTIVE_FRAMERATE 20
|
|
||||||
|
|
||||||
// DEBUG
|
// DEBUG
|
||||||
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
|
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
|
||||||
@@ -105,27 +107,6 @@ namespace graphics
|
|||||||
// A text message frame + debug frame + all the node infos
|
// A text message frame + debug frame + all the node infos
|
||||||
FrameCallback *normalFrames;
|
FrameCallback *normalFrames;
|
||||||
static uint32_t targetFramerate = IDLE_FRAMERATE;
|
static uint32_t targetFramerate = IDLE_FRAMERATE;
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
static inline void prepareFrameColorRegions()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
clearTFTColorRegions();
|
|
||||||
// Full-frame FrameMono inversion for themes that need it (e.g. light themes).
|
|
||||||
if (isThemeFullFrameInvert()) {
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(),
|
|
||||||
screen->getHeight());
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static inline void updateUiFrame(OLEDDisplayUi *ui)
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
prepareFrameColorRegions();
|
|
||||||
#endif
|
|
||||||
ui->update();
|
|
||||||
}
|
|
||||||
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
|
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
|
||||||
|
|
||||||
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
|
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
|
||||||
@@ -154,60 +135,6 @@ static bool heartbeat = false;
|
|||||||
|
|
||||||
extern bool hasUnreadMessage;
|
extern bool hasUnreadMessage;
|
||||||
|
|
||||||
static inline float wrapHeading360(float heading)
|
|
||||||
{
|
|
||||||
if (heading < 0.0f) {
|
|
||||||
heading += 360.0f;
|
|
||||||
} else if (heading >= 360.0f) {
|
|
||||||
heading -= 360.0f;
|
|
||||||
}
|
|
||||||
return heading;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Screen::setHeading(float heading)
|
|
||||||
{
|
|
||||||
const float wrappedHeading = wrapHeading360(heading);
|
|
||||||
|
|
||||||
if (!hasCompass) {
|
|
||||||
hasCompass = true;
|
|
||||||
compassHeading = wrappedHeading;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
|
|
||||||
float delta = wrappedHeading - compassHeading;
|
|
||||||
if (delta > 180.0f) {
|
|
||||||
delta -= 360.0f;
|
|
||||||
} else if (delta < -180.0f) {
|
|
||||||
delta += 360.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adaptive filtering:
|
|
||||||
// - Strong damping for tiny deltas (jitter)
|
|
||||||
// - Faster response for larger turns
|
|
||||||
const float absDelta = (delta >= 0.0f) ? delta : -delta;
|
|
||||||
if (absDelta < 1.0f) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
float alpha = 0.35f;
|
|
||||||
if (absDelta > 25.0f) {
|
|
||||||
alpha = 0.85f;
|
|
||||||
} else if (absDelta > 10.0f) {
|
|
||||||
alpha = 0.65f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float step = delta * alpha;
|
|
||||||
const float maxStep = 12.0f;
|
|
||||||
if (step > maxStep) {
|
|
||||||
step = maxStep;
|
|
||||||
} else if (step < -maxStep) {
|
|
||||||
step = -maxStep;
|
|
||||||
}
|
|
||||||
|
|
||||||
compassHeading = wrapHeading360(compassHeading + step);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==============================
|
// ==============================
|
||||||
// Overlay Alert Banner Renderer
|
// Overlay Alert Banner Renderer
|
||||||
// ==============================
|
// ==============================
|
||||||
@@ -244,7 +171,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
|
|||||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||||
ui->setTargetFPS(60);
|
ui->setTargetFPS(60);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called to trigger a banner with custom message and duration
|
// Called to trigger a banner with custom message and duration
|
||||||
@@ -266,7 +193,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct
|
|||||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||||
ui->setTargetFPS(60);
|
ui->setTargetFPS(60);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called to trigger a banner with custom message and duration
|
// Called to trigger a banner with custom message and duration
|
||||||
@@ -290,7 +217,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
|
|||||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||||
ui->setTargetFPS(60);
|
ui->setTargetFPS(60);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
|
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
|
||||||
@@ -313,7 +240,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t
|
|||||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||||
ui->setTargetFPS(60);
|
ui->setTargetFPS(60);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||||
@@ -345,25 +272,10 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
|
|||||||
float Screen::estimatedHeading(double lat, double lon)
|
float Screen::estimatedHeading(double lat, double lon)
|
||||||
{
|
{
|
||||||
static double oldLat, oldLon;
|
static double oldLat, oldLon;
|
||||||
static float b = -1.0f;
|
static float b;
|
||||||
static uint32_t lastHeadingAtMs = 0;
|
|
||||||
const uint32_t now = millis();
|
|
||||||
const uint32_t gpsUpdateIntervalSecs =
|
|
||||||
Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval);
|
|
||||||
uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs;
|
|
||||||
if (config.position.position_broadcast_smart_enabled) {
|
|
||||||
const uint32_t smartMinIntervalSecs = Default::getConfiguredOrDefault(
|
|
||||||
config.position.broadcast_smart_minimum_interval_secs, default_broadcast_smart_minimum_interval_secs);
|
|
||||||
if (smartMinIntervalSecs > effectiveUpdateIntervalSecs) {
|
|
||||||
effectiveUpdateIntervalSecs = smartMinIntervalSecs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Two expected update windows; keep arithmetic 32-bit to avoid pulling in larger 64-bit helpers.
|
|
||||||
const uint32_t headingStaleMs =
|
|
||||||
(effectiveUpdateIntervalSecs > (UINT32_MAX / 2000U)) ? UINT32_MAX : (effectiveUpdateIntervalSecs * 2000U);
|
|
||||||
|
|
||||||
if (oldLat == 0) {
|
if (oldLat == 0) {
|
||||||
// Need at least two position points before we can infer heading.
|
// just prepare for next time
|
||||||
oldLat = lat;
|
oldLat = lat;
|
||||||
oldLon = lon;
|
oldLon = lon;
|
||||||
|
|
||||||
@@ -371,20 +283,12 @@ float Screen::estimatedHeading(double lat, double lon)
|
|||||||
}
|
}
|
||||||
|
|
||||||
float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon);
|
float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon);
|
||||||
if (d < 10) { // haven't moved enough, keep previous heading (invalid until first real movement)
|
if (d < 10) // haven't moved enough, just keep current bearing
|
||||||
if (lastHeadingAtMs != 0 && (now - lastHeadingAtMs) >= headingStaleMs) {
|
|
||||||
// Heading is stale after prolonged no-movement; force reacquire.
|
|
||||||
b = -1.0f;
|
|
||||||
oldLat = lat;
|
|
||||||
oldLon = lon;
|
|
||||||
}
|
|
||||||
return b;
|
return b;
|
||||||
}
|
|
||||||
|
|
||||||
b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG;
|
b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG;
|
||||||
oldLat = lat;
|
oldLat = lat;
|
||||||
oldLon = lon;
|
oldLon = lon;
|
||||||
lastHeadingAtMs = now;
|
|
||||||
|
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
@@ -405,6 +309,30 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
|||||||
{
|
{
|
||||||
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
|
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
|
||||||
|
|
||||||
|
int32_t rawRGB = uiconfig.screen_rgb_color;
|
||||||
|
|
||||||
|
// Only validate the combined value once
|
||||||
|
if (rawRGB > 0 && rawRGB <= 255255255) {
|
||||||
|
LOG_INFO("Setting screen RGB color to user chosen: 0x%06X", rawRGB);
|
||||||
|
// Extract each component as a normal int first
|
||||||
|
int r = (rawRGB >> 16) & 0xFF;
|
||||||
|
int g = (rawRGB >> 8) & 0xFF;
|
||||||
|
int b = rawRGB & 0xFF;
|
||||||
|
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||||
|
TFT_MESH = COLOR565(static_cast<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
#ifdef TFT_MESH_OVERRIDE
|
||||||
|
} else if (rawRGB == 0) {
|
||||||
|
LOG_INFO("Setting screen RGB color to TFT_MESH_OVERRIDE: 0x%04X", TFT_MESH_OVERRIDE);
|
||||||
|
// Default to TFT_MESH_OVERRIDE if available
|
||||||
|
TFT_MESH = TFT_MESH_OVERRIDE;
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
// Default best readable yellow color
|
||||||
|
LOG_INFO("Setting screen RGB color to default: (255,255,128)");
|
||||||
|
TFT_MESH = COLOR565(255, 255, 128);
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
|
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
|
||||||
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
|
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
|
||||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||||
@@ -425,6 +353,11 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
|||||||
#elif defined(USE_SSD1306)
|
#elif defined(USE_SSD1306)
|
||||||
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
|
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
|
||||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||||
|
#if defined(OLED_Y_OFFSET_PAGES)
|
||||||
|
// Panels whose active window does not start at GDDRAM row 0 (e.g. 72x40
|
||||||
|
// modules on pages 3..7) need a fixed vertical page shift on every write.
|
||||||
|
static_cast<SSD1306Wire *>(dispdev)->setYOffset(OLED_Y_OFFSET_PAGES);
|
||||||
|
#endif
|
||||||
#elif defined(USE_SPISSD1306)
|
#elif defined(USE_SPISSD1306)
|
||||||
dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48);
|
dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48);
|
||||||
if (!dispdev->init()) {
|
if (!dispdev->init()) {
|
||||||
@@ -467,13 +400,9 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(USE_ST7789)
|
#if defined(USE_ST7789)
|
||||||
// Keep firmware and ST7789 driver region structs layout-compatible:
|
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||||
// we pass `graphics::colorRegions` through a type cast below.
|
|
||||||
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
|
|
||||||
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
|
|
||||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
|
|
||||||
#elif defined(USE_ST7796)
|
#elif defined(USE_ST7796)
|
||||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
ui = new OLEDDisplayUi(dispdev);
|
ui = new OLEDDisplayUi(dispdev);
|
||||||
@@ -524,11 +453,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
|||||||
delay(100);
|
delay(100);
|
||||||
#endif
|
#endif
|
||||||
#if !ARCH_PORTDUINO
|
#if !ARCH_PORTDUINO
|
||||||
#if defined(USE_ST7789) && defined(VTFT_CTRL)
|
|
||||||
// Ensure panel power rail is enabled before sending wake commands.
|
|
||||||
pinMode(VTFT_CTRL, OUTPUT);
|
|
||||||
digitalWrite(VTFT_CTRL, LOW);
|
|
||||||
#endif
|
|
||||||
dispdev->displayOn();
|
dispdev->displayOn();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -550,6 +474,10 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
|||||||
ui->init();
|
ui->init();
|
||||||
#endif
|
#endif
|
||||||
#if defined(USE_ST7789) && defined(VTFT_LEDA)
|
#if defined(USE_ST7789) && defined(VTFT_LEDA)
|
||||||
|
#ifdef VTFT_CTRL
|
||||||
|
pinMode(VTFT_CTRL, OUTPUT);
|
||||||
|
digitalWrite(VTFT_CTRL, LOW);
|
||||||
|
#endif
|
||||||
ui->init();
|
ui->init();
|
||||||
#ifdef ESP_PLATFORM
|
#ifdef ESP_PLATFORM
|
||||||
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
|
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
|
||||||
@@ -590,22 +518,23 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
|||||||
#endif
|
#endif
|
||||||
#ifdef USE_ST7789
|
#ifdef USE_ST7789
|
||||||
SPI1.end();
|
SPI1.end();
|
||||||
// Keep TFT control pins in deterministic states while timed-off.
|
#if defined(ARCH_ESP32)
|
||||||
// Floating/default pin states can corrupt panel edge rows on wake.
|
|
||||||
#ifdef VTFT_LEDA
|
#ifdef VTFT_LEDA
|
||||||
pinMode(VTFT_LEDA, OUTPUT);
|
pinMode(VTFT_LEDA, ANALOG);
|
||||||
digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON);
|
|
||||||
#endif
|
#endif
|
||||||
#ifdef VTFT_CTRL
|
#ifdef VTFT_CTRL
|
||||||
pinMode(VTFT_CTRL, OUTPUT);
|
pinMode(VTFT_CTRL, ANALOG);
|
||||||
digitalWrite(VTFT_CTRL, HIGH);
|
#endif
|
||||||
|
pinMode(ST7789_RESET, ANALOG);
|
||||||
|
pinMode(ST7789_RS, ANALOG);
|
||||||
|
pinMode(ST7789_NSS, ANALOG);
|
||||||
|
#else
|
||||||
|
nrf_gpio_cfg_default(VTFT_LEDA);
|
||||||
|
nrf_gpio_cfg_default(VTFT_CTRL);
|
||||||
|
nrf_gpio_cfg_default(ST7789_RESET);
|
||||||
|
nrf_gpio_cfg_default(ST7789_RS);
|
||||||
|
nrf_gpio_cfg_default(ST7789_NSS);
|
||||||
#endif
|
#endif
|
||||||
pinMode(ST7789_RESET, OUTPUT);
|
|
||||||
digitalWrite(ST7789_RESET, HIGH);
|
|
||||||
pinMode(ST7789_RS, OUTPUT);
|
|
||||||
digitalWrite(ST7789_RS, HIGH);
|
|
||||||
pinMode(ST7789_NSS, OUTPUT);
|
|
||||||
digitalWrite(ST7789_NSS, HIGH);
|
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_ST7796
|
#ifdef USE_ST7796
|
||||||
SPI1.end();
|
SPI1.end();
|
||||||
@@ -660,16 +589,16 @@ void Screen::setup()
|
|||||||
static_cast<SH1106Wire *>(dispdev)->setSubtype(7);
|
static_cast<SH1106Wire *>(dispdev)->setSubtype(7);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(USE_ST7789)
|
#if defined(USE_ST7789) && defined(TFT_MESH)
|
||||||
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
|
// Apply custom RGB color (e.g. Heltec T114/T190)
|
||||||
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
|
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
|
|
||||||
#endif
|
#endif
|
||||||
#if defined(MUZI_BASE)
|
#if defined(MUZI_BASE)
|
||||||
dispdev->delayPoweron = true;
|
dispdev->delayPoweron = true;
|
||||||
#endif
|
#endif
|
||||||
#if defined(USE_ST7796)
|
#if defined(USE_ST7796) && defined(TFT_MESH)
|
||||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
// Custom text color, if defined in variant.h
|
||||||
|
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Initialize display and UI system
|
// Initialize display and UI system
|
||||||
@@ -715,7 +644,7 @@ void Screen::setup()
|
|||||||
#endif
|
#endif
|
||||||
{
|
{
|
||||||
const char *region = myRegion ? myRegion->name : nullptr;
|
const char *region = myRegion ? myRegion->name : nullptr;
|
||||||
graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y);
|
graphics::UIRenderer::drawIconScreen(region, display, state, x, y);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ui->setFrames(alertFrames, 1);
|
ui->setFrames(alertFrames, 1);
|
||||||
@@ -754,9 +683,9 @@ void Screen::setup()
|
|||||||
// Turn on display and trigger first draw
|
// Turn on display and trigger first draw
|
||||||
handleSetOn(true);
|
handleSetOn(true);
|
||||||
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
|
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
#ifndef USE_EINK
|
#ifndef USE_EINK
|
||||||
updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice
|
ui->update(); // Some SSD1306 clones drop the first draw, so run twice
|
||||||
#endif
|
#endif
|
||||||
serialSinceMsec = millis();
|
serialSinceMsec = millis();
|
||||||
|
|
||||||
@@ -829,7 +758,7 @@ void Screen::forceDisplay(bool forceUiUpdate)
|
|||||||
do {
|
do {
|
||||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||||
delay(10);
|
delay(10);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||||
|
|
||||||
// Return to normal frame rate
|
// Return to normal frame rate
|
||||||
@@ -900,9 +829,9 @@ int32_t Screen::runOnce()
|
|||||||
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
|
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
|
||||||
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
|
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
|
||||||
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
|
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
#ifndef USE_EINK
|
#ifndef USE_EINK
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
#endif
|
#endif
|
||||||
showingOEMBootScreen = false;
|
showingOEMBootScreen = false;
|
||||||
}
|
}
|
||||||
@@ -910,7 +839,7 @@ int32_t Screen::runOnce()
|
|||||||
|
|
||||||
#ifndef DISABLE_WELCOME_UNSET
|
#ifndef DISABLE_WELCOME_UNSET
|
||||||
if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
menuHandler::LoraRegionPicker();
|
menuHandler::LoraRegionPicker();
|
||||||
#else
|
#else
|
||||||
menuHandler::OnboardMessage();
|
menuHandler::OnboardMessage();
|
||||||
@@ -993,28 +922,15 @@ int32_t Screen::runOnce()
|
|||||||
|
|
||||||
// this must be before the frameState == FIXED check, because we always
|
// this must be before the frameState == FIXED check, because we always
|
||||||
// want to draw at least one FIXED frame before doing forceDisplay
|
// want to draw at least one FIXED frame before doing forceDisplay
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
|
|
||||||
// Switch to a low framerate (to save CPU) when we are not in transition
|
// Switch to a low framerate (to save CPU) when we are not in transition
|
||||||
// but we should only call setTargetFPS when framestate changes, because
|
// but we should only call setTargetFPS when framestate changes, because
|
||||||
// otherwise that breaks animations.
|
// otherwise that breaks animations.
|
||||||
|
|
||||||
uint32_t desiredFramerate = IDLE_FRAMERATE;
|
if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) {
|
||||||
#if HAS_GPS && !defined(USE_EINK)
|
|
||||||
if (showingNormalScreen && hasCompass) {
|
|
||||||
const uint8_t currentFrame = ui->getUiState()->currentFrame;
|
|
||||||
if ((framesetInfo.positions.gps != 255 && currentFrame == framesetInfo.positions.gps) ||
|
|
||||||
(framesetInfo.positions.waypoint != 255 && currentFrame == framesetInfo.positions.waypoint) ||
|
|
||||||
(framesetInfo.positions.firstFavorite != 255 && currentFrame >= framesetInfo.positions.firstFavorite &&
|
|
||||||
currentFrame <= framesetInfo.positions.lastFavorite)) {
|
|
||||||
desiredFramerate = COMPASS_ACTIVE_FRAMERATE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (targetFramerate != desiredFramerate && ui->getUiState()->frameState == FIXED) {
|
|
||||||
// oldFrameState = ui->getUiState()->frameState;
|
// oldFrameState = ui->getUiState()->frameState;
|
||||||
targetFramerate = desiredFramerate;
|
targetFramerate = IDLE_FRAMERATE;
|
||||||
|
|
||||||
ui->setTargetFPS(targetFramerate);
|
ui->setTargetFPS(targetFramerate);
|
||||||
forceDisplay();
|
forceDisplay();
|
||||||
@@ -1055,7 +971,7 @@ void Screen::setSSLFrames()
|
|||||||
// LOG_DEBUG("Show SSL frames");
|
// LOG_DEBUG("Show SSL frames");
|
||||||
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
|
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
|
||||||
ui->setFrames(sslFrames, 1);
|
ui->setFrames(sslFrames, 1);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1091,7 +1007,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
|
|||||||
do {
|
do {
|
||||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||||
delay(1);
|
delay(1);
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||||
|
|
||||||
#if defined(USE_EINK_PARALLELDISPLAY)
|
#if defined(USE_EINK_PARALLELDISPLAY)
|
||||||
@@ -1147,7 +1063,7 @@ void Screen::setFrames(FrameFocus focus)
|
|||||||
#if defined(DISPLAY_CLOCK_FRAME)
|
#if defined(DISPLAY_CLOCK_FRAME)
|
||||||
if (!hiddenFrames.clock) {
|
if (!hiddenFrames.clock) {
|
||||||
fsi.positions.clock = numframes;
|
fsi.positions.clock = numframes;
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame;
|
normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame;
|
||||||
#else
|
#else
|
||||||
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
||||||
@@ -1286,7 +1202,7 @@ void Screen::setFrames(FrameFocus focus)
|
|||||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||||
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||||||
if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
|
if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
|
||||||
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,7 +1231,7 @@ void Screen::setFrames(FrameFocus focus)
|
|||||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||||
|
|
||||||
prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed)
|
prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed)
|
||||||
|
|
||||||
// Focus on a specific frame, in the frame set we just created
|
// Focus on a specific frame, in the frame set we just created
|
||||||
switch (focus) {
|
switch (focus) {
|
||||||
@@ -1466,15 +1382,9 @@ void Screen::blink()
|
|||||||
dispdev->setBrightness(254);
|
dispdev->setBrightness(254);
|
||||||
while (count > 0) {
|
while (count > 0) {
|
||||||
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
|
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
prepareFrameColorRegions();
|
|
||||||
#endif
|
|
||||||
dispdev->display();
|
dispdev->display();
|
||||||
delay(50);
|
delay(50);
|
||||||
dispdev->clear();
|
dispdev->clear();
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
prepareFrameColorRegions();
|
|
||||||
#endif
|
|
||||||
dispdev->display();
|
dispdev->display();
|
||||||
delay(50);
|
delay(50);
|
||||||
count = count - 1;
|
count = count - 1;
|
||||||
@@ -1606,11 +1516,8 @@ void Screen::showFrame(FrameDirection direction)
|
|||||||
|
|
||||||
void Screen::setFastFramerate()
|
void Screen::setFastFramerate()
|
||||||
{
|
{
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
dispdev->clear();
|
dispdev->clear();
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
prepareFrameColorRegions();
|
|
||||||
#endif
|
|
||||||
dispdev->display();
|
dispdev->display();
|
||||||
#endif
|
#endif
|
||||||
// We are about to start a transition so speed up fps
|
// We are about to start a transition so speed up fps
|
||||||
@@ -1822,7 +1729,7 @@ int Screen::handleInputEvent(const InputEvent *event)
|
|||||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||||
setFastFramerate(); // Draw ASAP
|
setFastFramerate(); // Draw ASAP
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1837,7 +1744,7 @@ int Screen::handleInputEvent(const InputEvent *event)
|
|||||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||||
setFastFramerate(); // Draw ASAP
|
setFastFramerate(); // Draw ASAP
|
||||||
updateUiFrame(ui);
|
ui->update();
|
||||||
|
|
||||||
menuHandler::handleMenuSwitch(dispdev);
|
menuHandler::handleMenuSwitch(dispdev);
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -330,11 +330,15 @@ class Screen : public concurrency::OSThread
|
|||||||
|
|
||||||
// Function to allow the AccelerometerThread to set the heading if a sensor provides it
|
// Function to allow the AccelerometerThread to set the heading if a sensor provides it
|
||||||
// Mutex needed?
|
// Mutex needed?
|
||||||
void setHeading(float heading);
|
void setHeading(long _heading)
|
||||||
|
{
|
||||||
|
hasCompass = true;
|
||||||
|
compassHeading = fmod(_heading, 360);
|
||||||
|
}
|
||||||
|
|
||||||
bool hasHeading() { return hasCompass; }
|
bool hasHeading() { return hasCompass; }
|
||||||
|
|
||||||
float getHeading() { return compassHeading; }
|
long getHeading() { return compassHeading; }
|
||||||
|
|
||||||
void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; }
|
void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; }
|
||||||
uint32_t getEndCalibration() { return endCalibrationAt; }
|
uint32_t getEndCalibration() { return endCalibrationAt; }
|
||||||
@@ -788,4 +792,4 @@ extern std::vector<std::string> functionSymbol;
|
|||||||
extern std::string functionSymbolString;
|
extern std::string functionSymbolString;
|
||||||
extern graphics::Screen *screen;
|
extern graphics::Screen *screen;
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
#define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19
|
#define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19
|
||||||
#define FONT_MEDIUM FONT_LARGE_LOCAL // Height: 28
|
#define FONT_MEDIUM FONT_LARGE_LOCAL // Height: 28
|
||||||
#define FONT_LARGE FONT_LARGE_LOCAL // Height: 28
|
#define FONT_LARGE FONT_LARGE_LOCAL // Height: 28
|
||||||
#elif defined(M5STACK_UNITC6L)
|
#elif defined(OLED_TINY)
|
||||||
#define FONT_SMALL FONT_SMALL_LOCAL // Height: 13
|
#define FONT_SMALL FONT_SMALL_LOCAL // Height: 13
|
||||||
#define FONT_MEDIUM FONT_SMALL_LOCAL // Height: 13
|
#define FONT_MEDIUM FONT_SMALL_LOCAL // Height: 13
|
||||||
#define FONT_LARGE FONT_SMALL_LOCAL // Height: 13
|
#define FONT_LARGE FONT_SMALL_LOCAL // Height: 13
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#if HAS_SCREEN
|
#if HAS_SCREEN
|
||||||
#include "MeshService.h"
|
#include "MeshService.h"
|
||||||
#include "NodeDB.h"
|
|
||||||
#include "RTC.h"
|
#include "RTC.h"
|
||||||
#include "draw/NodeListRenderer.h"
|
#include "draw/NodeListRenderer.h"
|
||||||
#include "graphics/ScreenFonts.h"
|
#include "graphics/ScreenFonts.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/TFTColorRegions.h"
|
|
||||||
#include "graphics/TFTPalette.h"
|
|
||||||
#include "graphics/draw/UIRenderer.h"
|
#include "graphics/draw/UIRenderer.h"
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
#include "meshtastic/config.pb.h"
|
#include "meshtastic/config.pb.h"
|
||||||
#include "modules/ExternalNotificationModule.h"
|
#include "modules/ExternalNotificationModule.h"
|
||||||
#include "power.h"
|
#include "power.h"
|
||||||
#include <OLEDDisplay.h>
|
#include <OLEDDisplay.h>
|
||||||
#include <cctype>
|
|
||||||
#include <graphics/images.h>
|
#include <graphics/images.h>
|
||||||
|
|
||||||
namespace graphics
|
namespace graphics
|
||||||
@@ -31,6 +27,12 @@ ScreenResolution determineScreenResolution(int16_t screenheight, int16_t screenw
|
|||||||
return ScreenResolution::UltraLow;
|
return ScreenResolution::UltraLow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef DISPLAY_FORCE_SMALL_FONTS
|
||||||
|
if (screenwidth <= 160 && screenheight <= 80) {
|
||||||
|
return ScreenResolution::Low;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Standard OLED screens
|
// Standard OLED screens
|
||||||
if (screenwidth > 128 && screenheight <= 64) {
|
if (screenwidth > 128 && screenheight <= 64) {
|
||||||
return ScreenResolution::Low;
|
return ScreenResolution::Low;
|
||||||
@@ -69,12 +71,6 @@ uint32_t lastBlinkShared = 0;
|
|||||||
bool isMailIconVisible = true;
|
bool isMailIconVisible = true;
|
||||||
uint32_t lastMailBlink = 0;
|
uint32_t lastMailBlink = 0;
|
||||||
|
|
||||||
static inline bool useClockHeaderAccentTheme(uint32_t themeId)
|
|
||||||
{
|
|
||||||
return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen ||
|
|
||||||
themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
|
|
||||||
}
|
|
||||||
|
|
||||||
// *********************************
|
// *********************************
|
||||||
// * Rounded Header when inverted *
|
// * Rounded Header when inverted *
|
||||||
// *********************************
|
// *********************************
|
||||||
@@ -95,8 +91,7 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
|
|||||||
// *************************
|
// *************************
|
||||||
// * Common Header Drawing *
|
// * Common Header Drawing *
|
||||||
// *************************
|
// *************************
|
||||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date,
|
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date)
|
||||||
bool transparent_background, bool use_title_color_override, uint16_t title_color_override)
|
|
||||||
{
|
{
|
||||||
constexpr int HEADER_OFFSET_Y = 1;
|
constexpr int HEADER_OFFSET_Y = 1;
|
||||||
y += HEADER_OFFSET_Y;
|
y += HEADER_OFFSET_Y;
|
||||||
@@ -111,93 +106,30 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
|
|
||||||
const int screenW = display->getWidth();
|
const int screenW = display->getWidth();
|
||||||
const int screenH = display->getHeight();
|
const int screenH = display->getHeight();
|
||||||
const int headerHeight = highlightHeight + 2;
|
|
||||||
const uint16_t headerColorForRoles = getThemeHeaderBg();
|
|
||||||
// Color TFT headers use a fixed dark background + white glyphs.
|
|
||||||
// Keep legacy inverted bitmap behavior only for monochrome displays.
|
|
||||||
const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background);
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
int statusLeftEndX = 0;
|
|
||||||
int statusRightStartX = screenW;
|
|
||||||
const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0');
|
|
||||||
const auto activeThemeId = getActiveTheme().id;
|
|
||||||
const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
{
|
|
||||||
const uint16_t headerColor = getThemeHeaderBg();
|
|
||||||
const uint16_t headerTextColor = getThemeHeaderText();
|
|
||||||
const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor;
|
|
||||||
uint16_t headerStatusColor = getThemeHeaderStatus();
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
// Clock frame uses transparent header + date + empty title.
|
|
||||||
// For accent clock themes (Pink/Creamsicle + classic monochrome), tint
|
|
||||||
// status items (battery outline, %, date, mail icon) to the header accent.
|
|
||||||
if (useClockHeaderAccent) {
|
|
||||||
headerStatusColor = getThemeHeaderBg();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparent_background) {
|
|
||||||
// Transparent clock headers should inherit whatever body off-color is
|
|
||||||
// already active under the header (important for light/inverted themes).
|
|
||||||
const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg());
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW,
|
|
||||||
headerHeight);
|
|
||||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor);
|
|
||||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor);
|
|
||||||
} else if (useInvertedHeaderStyle) {
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW,
|
|
||||||
headerHeight);
|
|
||||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole);
|
|
||||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor);
|
|
||||||
} else {
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW,
|
|
||||||
headerHeight);
|
|
||||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor);
|
|
||||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
if (!force_no_invert) {
|
||||||
// === Inverted Header Background ===
|
// === Inverted Header Background ===
|
||||||
if (useInvertedHeaderStyle) {
|
if (isInverted) {
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
display->fillRect(0, 0, screenW, headerHeight);
|
display->fillRect(0, 0, screenW, highlightHeight + 2);
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
|
drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
} else {
|
} else {
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
display->fillRect(0, 0, screenW, headerHeight);
|
display->fillRect(0, 0, screenW, highlightHeight + 2);
|
||||||
// Keep the legacy white separator for monochrome displays only when header background is visible.
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (!transparent_background) {
|
|
||||||
display->setColor(WHITE);
|
|
||||||
if (currentResolution == ScreenResolution::High) {
|
|
||||||
display->drawLine(0, 20, screenW, 20);
|
|
||||||
} else {
|
|
||||||
display->drawLine(0, 14, screenW, 14);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparent_background) {
|
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
|
if (currentResolution == ScreenResolution::High) {
|
||||||
|
display->drawLine(0, 20, screenW, 20);
|
||||||
|
} else {
|
||||||
|
display->drawLine(0, 14, screenW, 14);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
// TFT role coloring expects foreground glyph bits to be "set".
|
|
||||||
display->setColor(WHITE);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// === Screen Title ===
|
// === Screen Title ===
|
||||||
const char *headerTitle = titleStr ? titleStr : "";
|
const char *headerTitle = titleStr ? titleStr : "";
|
||||||
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
|
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
|
||||||
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
|
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2);
|
|
||||||
registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL);
|
|
||||||
#endif
|
|
||||||
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
|
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
|
||||||
}
|
}
|
||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
@@ -226,21 +158,10 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
|
|
||||||
bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH);
|
bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH);
|
||||||
const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
|
const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
|
||||||
bool hasBatteryFillRegion = false;
|
|
||||||
int16_t batteryFillRegionX = 0;
|
|
||||||
int16_t batteryFillRegionY = 0;
|
|
||||||
int16_t batteryFillRegionW = 0;
|
|
||||||
int16_t batteryFillRegionH = 0;
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent);
|
|
||||||
if (useClockHeaderAccent) {
|
|
||||||
batteryFillColor = getThemeHeaderBg();
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
int batteryX = 1;
|
int batteryX = 1;
|
||||||
int batteryY = HEADER_OFFSET_Y + 1;
|
int batteryY = HEADER_OFFSET_Y + 1;
|
||||||
#if !defined(M5STACK_UNITC6L)
|
#if !defined(OLED_TINY)
|
||||||
// === Battery Icons ===
|
// === Battery Icons ===
|
||||||
if (usbPowered && !isCharging) { // This is a basic check to determine USB Powered is flagged but not charging
|
if (usbPowered && !isCharging) { // This is a basic check to determine USB Powered is flagged but not charging
|
||||||
batteryX += 1;
|
batteryX += 1;
|
||||||
@@ -265,15 +186,6 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12);
|
display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12);
|
||||||
int fillWidth = 14 * chargePercent / 100;
|
int fillWidth = 14 * chargePercent / 100;
|
||||||
display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11);
|
display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (fillWidth > 0) {
|
|
||||||
hasBatteryFillRegion = true;
|
|
||||||
batteryFillRegionX = batteryX + 1;
|
|
||||||
batteryFillRegionY = batteryY + 1;
|
|
||||||
batteryFillRegionW = fillWidth;
|
|
||||||
batteryFillRegionH = 11;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
batteryX += 18; // Icon + 2 pixels
|
batteryX += 18; // Icon + 2 pixels
|
||||||
} else {
|
} else {
|
||||||
@@ -288,41 +200,21 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
int fillHeight = 8 * chargePercent / 100;
|
int fillHeight = 8 * chargePercent / 100;
|
||||||
int fillY = batteryY - fillHeight;
|
int fillY = batteryY - fillHeight;
|
||||||
display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight);
|
display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (fillHeight > 0) {
|
|
||||||
hasBatteryFillRegion = true;
|
|
||||||
batteryFillRegionX = batteryX + 1;
|
|
||||||
batteryFillRegionY = fillY + 10;
|
|
||||||
batteryFillRegionW = 5;
|
|
||||||
batteryFillRegionH = fillHeight;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
batteryX += 9; // Icon + 2 pixels
|
batteryX += 9; // Icon + 2 pixels
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
statusLeftEndX = batteryX + 2;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (chargePercent != 101) {
|
if (chargePercent != 101) {
|
||||||
// === Battery % Display ===
|
// === Battery % Display ===
|
||||||
char chargeStr[4];
|
char chargeStr[4];
|
||||||
snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent);
|
snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent);
|
||||||
int chargeNumWidth = display->getStringWidth(chargeStr);
|
int chargeNumWidth = display->getStringWidth(chargeStr);
|
||||||
const int percentWidth = display->getStringWidth("%");
|
|
||||||
const int percentX = batteryX + chargeNumWidth - 1;
|
|
||||||
display->drawString(batteryX, textY, chargeStr);
|
display->drawString(batteryX, textY, chargeStr);
|
||||||
display->drawString(percentX, textY, "%");
|
display->drawString(batteryX + chargeNumWidth - 1, textY, "%");
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
statusLeftEndX = percentX + percentWidth + 2;
|
|
||||||
#endif
|
|
||||||
if (isBold) {
|
if (isBold) {
|
||||||
display->drawString(batteryX + 1, textY, chargeStr);
|
display->drawString(batteryX + 1, textY, chargeStr);
|
||||||
display->drawString(percentX + 1, textY, "%");
|
display->drawString(batteryX + chargeNumWidth, textY, "%");
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
statusLeftEndX = percentX + percentWidth + 3;
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,9 +259,6 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
timeStrWidth = display->getStringWidth(timeStr);
|
timeStrWidth = display->getStringWidth(timeStr);
|
||||||
}
|
}
|
||||||
timeX = screenW - xOffset - timeStrWidth + 3;
|
timeX = screenW - xOffset - timeStrWidth + 3;
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// === Show Mail or Mute Icon to the Left of Time ===
|
// === Show Mail or Mute Icon to the Left of Time ===
|
||||||
int iconRightEdge = timeX - 2;
|
int iconRightEdge = timeX - 2;
|
||||||
@@ -395,7 +284,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
int iconW = 16, iconH = 12;
|
int iconW = 16, iconH = 12;
|
||||||
int iconX = iconRightEdge - iconW;
|
int iconX = iconRightEdge - iconW;
|
||||||
int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
|
int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
|
||||||
if (useInvertedHeaderStyle) {
|
if (isInverted && !force_no_invert) {
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
|
display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
@@ -410,7 +299,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
} else {
|
} else {
|
||||||
int iconX = iconRightEdge - (mail_width - 2);
|
int iconX = iconRightEdge - (mail_width - 2);
|
||||||
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
||||||
if (useInvertedHeaderStyle) {
|
if (isInverted && !force_no_invert) {
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
|
display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
@@ -426,7 +315,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
int iconX = iconRightEdge - mute_symbol_big_width;
|
int iconX = iconRightEdge - mute_symbol_big_width;
|
||||||
int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
|
int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
|
||||||
|
|
||||||
if (useInvertedHeaderStyle) {
|
if (isInverted && !force_no_invert) {
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
|
display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
@@ -440,7 +329,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
int iconX = iconRightEdge - mute_symbol_width;
|
int iconX = iconRightEdge - mute_symbol_width;
|
||||||
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
||||||
|
|
||||||
if (useInvertedHeaderStyle) {
|
if (isInverted && !force_no_invert) {
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
|
display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
@@ -468,9 +357,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
} else {
|
} else {
|
||||||
// === No Time Available: Mail/Mute Icon Moves to Far Right ===
|
// === No Time Available: Mail/Mute Icon Moves to Far Right ===
|
||||||
int iconRightEdge = screenW - xOffset;
|
int iconRightEdge = screenW - xOffset;
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12);
|
|
||||||
#endif
|
|
||||||
bool showMail = false;
|
bool showMail = false;
|
||||||
|
|
||||||
#ifndef USE_EINK
|
#ifndef USE_EINK
|
||||||
@@ -512,16 +399,6 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight);
|
|
||||||
if (statusRightStartX < screenW) {
|
|
||||||
registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight);
|
|
||||||
}
|
|
||||||
if (hasBatteryFillRegion) {
|
|
||||||
registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH,
|
|
||||||
batteryFillColor, headerColorForRoles);
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
display->setColor(WHITE); // Reset for other UI
|
display->setColor(WHITE); // Reset for other UI
|
||||||
}
|
}
|
||||||
@@ -559,23 +436,14 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
|
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
|
||||||
const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale);
|
|
||||||
const int footerH = (connection_icon_height * scale) + (2 * scale);
|
|
||||||
const int iconX = 0;
|
|
||||||
const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale);
|
|
||||||
const int iconW = connection_icon_width * scale;
|
|
||||||
const int iconH = connection_icon_height * scale;
|
|
||||||
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
// Only tint the link glyph itself on TFT; keep the footer background black.
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
|
display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
|
||||||
|
(connection_icon_height * scale) + (2 * scale));
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
const int bytesPerRow = (connection_icon_width + 7) / 8;
|
const int bytesPerRow = (connection_icon_width + 7) / 8;
|
||||||
|
int iconX = 0;
|
||||||
|
int iconY = SCREEN_HEIGHT - (connection_icon_height * 2);
|
||||||
|
|
||||||
for (int yy = 0; yy < connection_icon_height; ++yy) {
|
for (int yy = 0; yy < connection_icon_height; ++yy) {
|
||||||
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
|
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
|
||||||
@@ -589,127 +457,65 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon);
|
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
|
||||||
|
connection_icon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isAllowedPunctuation(char c)
|
bool isAllowedPunctuation(char c)
|
||||||
{
|
{
|
||||||
switch (c) {
|
const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ ";
|
||||||
case '.':
|
return allowed.find(c) != std::string::npos;
|
||||||
case ',':
|
|
||||||
case '!':
|
|
||||||
case '?':
|
|
||||||
case ';':
|
|
||||||
case ':':
|
|
||||||
case '-':
|
|
||||||
case '_':
|
|
||||||
case '(':
|
|
||||||
case ')':
|
|
||||||
case '[':
|
|
||||||
case ']':
|
|
||||||
case '{':
|
|
||||||
case '}':
|
|
||||||
case '\'':
|
|
||||||
case '"':
|
|
||||||
case '@':
|
|
||||||
case '#':
|
|
||||||
case '$':
|
|
||||||
case '/':
|
|
||||||
case '\\':
|
|
||||||
case '&':
|
|
||||||
case '+':
|
|
||||||
case '=':
|
|
||||||
case '%':
|
|
||||||
case '~':
|
|
||||||
case '^':
|
|
||||||
case ' ':
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static inline size_t utf8CodePointLength(unsigned char lead)
|
static void replaceAll(std::string &s, const std::string &from, const std::string &to)
|
||||||
{
|
{
|
||||||
if ((lead & 0x80) == 0x00) {
|
if (from.empty())
|
||||||
return 1;
|
return;
|
||||||
|
size_t pos = 0;
|
||||||
|
while ((pos = s.find(from, pos)) != std::string::npos) {
|
||||||
|
s.replace(pos, from.size(), to);
|
||||||
|
pos += to.size();
|
||||||
}
|
}
|
||||||
if ((lead & 0xE0) == 0xC0) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
if ((lead & 0xF0) == 0xE0) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
if ((lead & 0xF8) == 0xF0) {
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string sanitizeString(const std::string &input)
|
std::string sanitizeString(const std::string &input)
|
||||||
{
|
{
|
||||||
static constexpr char kReplacementChar = static_cast<char>(0xBF); // Inverted question mark in ISO-8859-1.
|
|
||||||
std::string output;
|
std::string output;
|
||||||
output.reserve(input.size());
|
|
||||||
bool inReplacement = false;
|
bool inReplacement = false;
|
||||||
const size_t inputSize = input.size();
|
|
||||||
size_t i = 0;
|
// Make a mutable copy so we can normalize UTF-8 “smart punctuation” into ASCII first.
|
||||||
while (i < inputSize) {
|
std::string s = input;
|
||||||
const unsigned char byte0 = static_cast<unsigned char>(input[i]);
|
|
||||||
char normalized = '\0';
|
// Curly single quotes: ‘ ’
|
||||||
size_t consumed = 0;
|
replaceAll(s, "\xE2\x80\x98", "'"); // U+2018
|
||||||
if (byte0 < 0x80) {
|
replaceAll(s, "\xE2\x80\x99", "'"); // U+2019
|
||||||
normalized = static_cast<char>(byte0);
|
|
||||||
consumed = 1;
|
// Curly double quotes: “ ”
|
||||||
} else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast<unsigned char>(input[i + 1]) == 0x80) {
|
replaceAll(s, "\xE2\x80\x9C", "\""); // U+201C
|
||||||
// Smart punctuation: ' ' \" \" - -
|
replaceAll(s, "\xE2\x80\x9D", "\""); // U+201D
|
||||||
switch (static_cast<unsigned char>(input[i + 2])) {
|
|
||||||
case 0x98:
|
// En dash / Em dash: – —
|
||||||
case 0x99:
|
replaceAll(s, "\xE2\x80\x93", "-"); // U+2013
|
||||||
normalized = '\'';
|
replaceAll(s, "\xE2\x80\x94", "-"); // U+2014
|
||||||
consumed = 3;
|
|
||||||
break;
|
// Non-breaking space
|
||||||
case 0x9C:
|
replaceAll(s, "\xC2\xA0", " "); // U+00A0
|
||||||
case 0x9D:
|
|
||||||
normalized = '\"';
|
// Now do your original sanitize pass over the normalized string.
|
||||||
consumed = 3;
|
for (unsigned char uc : s) {
|
||||||
break;
|
char c = static_cast<char>(uc);
|
||||||
case 0x93:
|
if (std::isalnum(uc) || isAllowedPunctuation(c)) {
|
||||||
case 0x94:
|
output += c;
|
||||||
normalized = '-';
|
inReplacement = false;
|
||||||
consumed = 3;
|
} else {
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast<unsigned char>(input[i + 1]) == 0xA0) {
|
|
||||||
// Non-breaking space.
|
|
||||||
normalized = ' ';
|
|
||||||
consumed = 2;
|
|
||||||
}
|
|
||||||
if (consumed == 0) {
|
|
||||||
size_t seqLen = utf8CodePointLength(byte0);
|
|
||||||
if (seqLen > (inputSize - i)) {
|
|
||||||
seqLen = 1;
|
|
||||||
}
|
|
||||||
if (!inReplacement) {
|
if (!inReplacement) {
|
||||||
output.push_back(kReplacementChar);
|
output += static_cast<char>(0xBF); // ISO-8859-1 for inverted question mark
|
||||||
inReplacement = true;
|
inReplacement = true;
|
||||||
}
|
}
|
||||||
i += seqLen;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const unsigned char normalizedUc = static_cast<unsigned char>(normalized);
|
|
||||||
if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) {
|
|
||||||
output.push_back(normalized);
|
|
||||||
inReplacement = false;
|
|
||||||
} else if (!inReplacement) {
|
|
||||||
output.push_back(kReplacementChar);
|
|
||||||
inReplacement = true;
|
|
||||||
}
|
|
||||||
i += consumed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <OLEDDisplay.h>
|
#include <OLEDDisplay.h>
|
||||||
#include <stdint.h>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace graphics
|
namespace graphics
|
||||||
@@ -53,8 +52,7 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
|
|||||||
|
|
||||||
// Shared battery/time/mail header
|
// Shared battery/time/mail header
|
||||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false,
|
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false,
|
||||||
bool show_date = false, bool transparent_background = false, bool use_title_color_override = false,
|
bool show_date = false);
|
||||||
uint16_t title_color_override = 0);
|
|
||||||
|
|
||||||
// Shared battery/time/mail header
|
// Shared battery/time/mail header
|
||||||
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
|
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
|
||||||
|
|||||||
@@ -1,819 +0,0 @@
|
|||||||
#include "TFTColorRegions.h"
|
|
||||||
#include "NodeDB.h"
|
|
||||||
#include "TFTPalette.h"
|
|
||||||
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
namespace graphics
|
|
||||||
{
|
|
||||||
TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
|
|
||||||
struct TFTRoleColorsBe {
|
|
||||||
uint16_t onColorBe;
|
|
||||||
uint16_t offColorBe;
|
|
||||||
};
|
|
||||||
|
|
||||||
static uint8_t colorRegionCount = 0;
|
|
||||||
static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u;
|
|
||||||
static constexpr uint32_t kFnv1aPrime = 16777619u;
|
|
||||||
|
|
||||||
static constexpr uint16_t toBe565(uint16_t color)
|
|
||||||
{
|
|
||||||
return static_cast<uint16_t>((color >> 8) | (color << 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
static constexpr bool kRoleIsBody[static_cast<size_t>(TFTColorRole::Count)] = {
|
|
||||||
false, // HeaderBackground
|
|
||||||
false, // HeaderTitle
|
|
||||||
false, // HeaderStatus
|
|
||||||
true, // SignalBars
|
|
||||||
true, // ConnectionIcon
|
|
||||||
true, // UtilizationFill
|
|
||||||
true, // FavoriteNode
|
|
||||||
true, // ActionMenuBorder
|
|
||||||
true, // ActionMenuBody
|
|
||||||
true, // ActionMenuTitle
|
|
||||||
true, // FrameMono
|
|
||||||
false, // BootSplash
|
|
||||||
true, // FavoriteNodeBGHighlight
|
|
||||||
false, // NavigationBar
|
|
||||||
false // NavigationArrow
|
|
||||||
};
|
|
||||||
|
|
||||||
static inline bool isBodyColorRole(TFTColorRole role)
|
|
||||||
{
|
|
||||||
return kRoleIsBody[static_cast<size_t>(role)];
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline bool isMonochromeTheme(uint32_t themeId)
|
|
||||||
{
|
|
||||||
return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline uint16_t getMonochromeAccent(uint32_t themeId)
|
|
||||||
{
|
|
||||||
return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen
|
|
||||||
: (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed
|
|
||||||
: TFTPalette::White;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to)
|
|
||||||
{
|
|
||||||
if (value == from) {
|
|
||||||
value = to;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value)
|
|
||||||
{
|
|
||||||
return (hash ^ value) * kFnv1aPrime;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value)
|
|
||||||
{
|
|
||||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>(value & 0xFF));
|
|
||||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>((value >> 8) & 0xFF));
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile-time header color overrides (backward-compatible)
|
|
||||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
|
||||||
static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE;
|
|
||||||
#else
|
|
||||||
static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
|
||||||
static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
|
||||||
#else
|
|
||||||
static constexpr uint16_t kTitleColor = TFTPalette::White;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
|
||||||
static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
|
||||||
#else
|
|
||||||
static constexpr uint16_t kStatusColor = TFTPalette::White;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Theme definitions
|
|
||||||
// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color
|
|
||||||
// against each entry's .uniqueIdentifier field.
|
|
||||||
|
|
||||||
static const TFTThemeDef kThemes[] = {
|
|
||||||
|
|
||||||
// Default Dark (ThemeID::DefaultDark = 0)
|
|
||||||
{
|
|
||||||
ThemeID::DefaultDark, // id
|
|
||||||
"Default Dark", // name
|
|
||||||
0, // uniqueIdentifier
|
|
||||||
// roles[TFTColorRole::Count]
|
|
||||||
{
|
|
||||||
{kHeaderBackground, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{kHeaderBackground, kTitleColor}, // HeaderTitle
|
|
||||||
{kHeaderBackground, kStatusColor}, // HeaderStatus
|
|
||||||
{TFTPalette::Good, TFTPalette::Black}, // SignalBars
|
|
||||||
{TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon
|
|
||||||
{TFTPalette::Good, TFTPalette::Black}, // UtilizationFill
|
|
||||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode
|
|
||||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
|
||||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
|
||||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
|
||||||
{kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg)
|
|
||||||
{kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
|
||||||
},
|
|
||||||
TFTPalette::Good, // batteryFillGood
|
|
||||||
TFTPalette::Medium, // batteryFillMedium
|
|
||||||
TFTPalette::Bad, // batteryFillBad
|
|
||||||
false, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Default Light (ThemeID::DefaultLight = 1)
|
|
||||||
{
|
|
||||||
ThemeID::DefaultLight, // id
|
|
||||||
"Default Light", // name
|
|
||||||
1, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle
|
|
||||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus
|
|
||||||
{TFTPalette::Good, TFTPalette::White}, // SignalBars
|
|
||||||
{TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon
|
|
||||||
{TFTPalette::Good, TFTPalette::White}, // UtilizationFill
|
|
||||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode
|
|
||||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder
|
|
||||||
{TFTPalette::Black, TFTPalette::White}, // ActionMenuBody
|
|
||||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
|
||||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg)
|
|
||||||
{TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
|
||||||
},
|
|
||||||
TFTPalette::Good, // batteryFillGood
|
|
||||||
TFTPalette::Medium, // batteryFillMedium
|
|
||||||
TFTPalette::Bad, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Christmas (ThemeID::Christmas = 2)
|
|
||||||
{
|
|
||||||
ThemeID::Christmas, // id
|
|
||||||
"Christmas", // name
|
|
||||||
2, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle
|
|
||||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus
|
|
||||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars
|
|
||||||
{TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon
|
|
||||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill
|
|
||||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode
|
|
||||||
{TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder
|
|
||||||
{TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody
|
|
||||||
{TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Pine, TFTPalette::White}, // FrameMono
|
|
||||||
{TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash
|
|
||||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg)
|
|
||||||
{TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg)
|
|
||||||
},
|
|
||||||
TFTPalette::ChristmasGreen, // batteryFillGood
|
|
||||||
TFTPalette::Gold, // batteryFillMedium
|
|
||||||
TFTPalette::ChristmasRed, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
false, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Pink (ThemeID::Pink = 3) light variant
|
|
||||||
{
|
|
||||||
ThemeID::Pink, // id
|
|
||||||
"Pink", // name
|
|
||||||
3, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle
|
|
||||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus
|
|
||||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars
|
|
||||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon
|
|
||||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill
|
|
||||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode
|
|
||||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder
|
|
||||||
{TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody
|
|
||||||
{TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
|
||||||
{TFTPalette::White, TFTPalette::HotPink}, // BootSplash
|
|
||||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg)
|
|
||||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg)
|
|
||||||
},
|
|
||||||
TFTPalette::DeepPink, // batteryFillGood
|
|
||||||
TFTPalette::HotPink, // batteryFillMedium
|
|
||||||
TFTPalette::Bad, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Blue (ThemeID::Blue = 4) dark variant
|
|
||||||
{
|
|
||||||
ThemeID::Blue, // id
|
|
||||||
"Blue", // name
|
|
||||||
4, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle
|
|
||||||
{TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus
|
|
||||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars
|
|
||||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon
|
|
||||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill
|
|
||||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode
|
|
||||||
{TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder
|
|
||||||
{TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody
|
|
||||||
{TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Navy, TFTPalette::White}, // FrameMono
|
|
||||||
{TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash
|
|
||||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg)
|
|
||||||
{TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
|
||||||
},
|
|
||||||
TFTPalette::SkyBlue, // batteryFillGood
|
|
||||||
TFTPalette::Medium, // batteryFillMedium
|
|
||||||
TFTPalette::Bad, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Creamsicle (ThemeID::Creamsicle = 5)light variant
|
|
||||||
{
|
|
||||||
ThemeID::Creamsicle, // id
|
|
||||||
"Creamsicle", // name
|
|
||||||
5, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle
|
|
||||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus
|
|
||||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars
|
|
||||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon
|
|
||||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill
|
|
||||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode
|
|
||||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder
|
|
||||||
{TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody
|
|
||||||
{TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
|
||||||
{TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash
|
|
||||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg)
|
|
||||||
{TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
|
||||||
},
|
|
||||||
TFTPalette::DeepOrange, // batteryFillGood
|
|
||||||
TFTPalette::Gold, // batteryFillMedium
|
|
||||||
TFTPalette::Bad, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome
|
|
||||||
// Pure single-color-on-black look. Every role maps foreground pixels to
|
|
||||||
// the theme color and background pixels to Black.
|
|
||||||
{
|
|
||||||
ThemeID::MeshtasticGreen, // id
|
|
||||||
"Meshtastic Green", // name
|
|
||||||
6, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg)
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar
|
|
||||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow
|
|
||||||
},
|
|
||||||
TFTPalette::Black, // batteryFillGood
|
|
||||||
TFTPalette::Black, // batteryFillMedium
|
|
||||||
TFTPalette::Black, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Classic Red (ThemeID::ClassicRed = 7) classic monochrome
|
|
||||||
{
|
|
||||||
ThemeID::ClassicRed, // id
|
|
||||||
"Classic Red", // name
|
|
||||||
7, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg)
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar
|
|
||||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow
|
|
||||||
},
|
|
||||||
TFTPalette::Black, // batteryFillGood
|
|
||||||
TFTPalette::Black, // batteryFillMedium
|
|
||||||
TFTPalette::Black, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
|
|
||||||
// Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome
|
|
||||||
{
|
|
||||||
ThemeID::MonochromeWhite, // id
|
|
||||||
"Monochrome White", // name
|
|
||||||
8, // uniqueIdentifier
|
|
||||||
{
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // HeaderBackground
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // HeaderTitle
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // HeaderStatus
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // SignalBars
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // ConnectionIcon
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // UtilizationFill
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNode
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle
|
|
||||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg)
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // NavigationBar
|
|
||||||
{TFTPalette::White, TFTPalette::Black}, // NavigationArrow
|
|
||||||
},
|
|
||||||
TFTPalette::Black, // batteryFillGood
|
|
||||||
TFTPalette::Black, // batteryFillMedium
|
|
||||||
TFTPalette::Black, // batteryFillBad
|
|
||||||
true, // fullFrameInvert
|
|
||||||
true, // visible
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]);
|
|
||||||
|
|
||||||
// Resolve the kThemes[] index for the currently persisted theme. Called at
|
|
||||||
// boot (indirectly via getActiveTheme()) and whenever the active theme is
|
|
||||||
// queried, so uiconfig.screen_rgb_color remains the single source of truth.
|
|
||||||
// Matches against .uniqueIdentifier - that's the field whose value is stored
|
|
||||||
// in the user's config. Falls back to 0 (DefaultDark) if no match is found,
|
|
||||||
// which gracefully handles removed or retired themes.
|
|
||||||
static inline size_t resolveThemeIndex()
|
|
||||||
{
|
|
||||||
const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F;
|
|
||||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
|
||||||
if (kThemes[i].uniqueIdentifier == savedIdentifier)
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
return 0; // Default Dark fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height)
|
|
||||||
{
|
|
||||||
if (width <= 0 || height <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (x < 0) {
|
|
||||||
width += x;
|
|
||||||
x = 0;
|
|
||||||
}
|
|
||||||
if (y < 0) {
|
|
||||||
height += y;
|
|
||||||
y = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return width > 0 && height > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe)
|
|
||||||
{
|
|
||||||
// Keep the last slot permanently disabled as a sentinel for ST7789 scans.
|
|
||||||
// This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries.
|
|
||||||
if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) {
|
|
||||||
memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2));
|
|
||||||
colorRegionCount = MAX_TFT_COLOR_REGIONS - 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
TFTColorRegion ®ion = colorRegions[colorRegionCount++];
|
|
||||||
region.x = x;
|
|
||||||
region.y = y;
|
|
||||||
region.width = width;
|
|
||||||
region.height = height;
|
|
||||||
region.onColorBe = onColorBe;
|
|
||||||
region.offColorBe = offColorBe;
|
|
||||||
region.enabled = true;
|
|
||||||
|
|
||||||
// Keep one disabled sentinel after the active range for ST7789 countColorRegions().
|
|
||||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
|
||||||
colorRegions[colorRegionCount].enabled = false;
|
|
||||||
}
|
|
||||||
colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current working role colors (big-endian). Initialised to Dark defaults;
|
|
||||||
// call loadThemeDefaults() after boot / theme change to refresh.
|
|
||||||
static TFTRoleColorsBe roleColors[static_cast<size_t>(TFTColorRole::Count)] = {
|
|
||||||
{toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground
|
|
||||||
{toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle
|
|
||||||
{toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus
|
|
||||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars
|
|
||||||
{toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon
|
|
||||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill
|
|
||||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode
|
|
||||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder
|
|
||||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody
|
|
||||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle
|
|
||||||
{toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono
|
|
||||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash
|
|
||||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight
|
|
||||||
{toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar
|
|
||||||
{toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
// Theme accessors
|
|
||||||
|
|
||||||
const TFTThemeDef &getActiveTheme()
|
|
||||||
{
|
|
||||||
return kThemes[resolveThemeIndex()];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visible-theme accessors
|
|
||||||
// These iterate only themes flagged .visible = true, preserving kThemes[]
|
|
||||||
// order. Menu code should use these so hidden themes don't appear in the
|
|
||||||
// picker while still applying correctly if their ID is persisted.
|
|
||||||
|
|
||||||
size_t getVisibleThemeCount()
|
|
||||||
{
|
|
||||||
size_t count = 0;
|
|
||||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
|
||||||
if (kThemes[i].visible)
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex)
|
|
||||||
{
|
|
||||||
size_t seen = 0;
|
|
||||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
|
||||||
if (!kThemes[i].visible)
|
|
||||||
continue;
|
|
||||||
if (seen == visibleIndex)
|
|
||||||
return kThemes[i];
|
|
||||||
seen++;
|
|
||||||
}
|
|
||||||
// Fallback: return first theme (never trust a bad index).
|
|
||||||
return kThemes[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t getActiveVisibleThemeIndex()
|
|
||||||
{
|
|
||||||
const size_t active = resolveThemeIndex();
|
|
||||||
if (!kThemes[active].visible)
|
|
||||||
return SIZE_MAX;
|
|
||||||
size_t visibleIdx = 0;
|
|
||||||
for (size_t i = 0; i < active; i++) {
|
|
||||||
if (kThemes[i].visible)
|
|
||||||
visibleIdx++;
|
|
||||||
}
|
|
||||||
return visibleIdx;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t getThemeHeaderBg()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
|
||||||
return TFT_HEADER_BG_COLOR_OVERRIDE;
|
|
||||||
#else
|
|
||||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderBackground)].onColor;
|
|
||||||
#endif
|
|
||||||
#else
|
|
||||||
return TFTPalette::DarkGray;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t getThemeHeaderText()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
|
||||||
return TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
|
||||||
#else
|
|
||||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderTitle)].offColor;
|
|
||||||
#endif
|
|
||||||
#else
|
|
||||||
return TFTPalette::White;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t getThemeHeaderStatus()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
|
||||||
return TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
|
||||||
#else
|
|
||||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderStatus)].offColor;
|
|
||||||
#endif
|
|
||||||
#else
|
|
||||||
return TFTPalette::White;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t getThemeBodyBg()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].onColor;
|
|
||||||
#else
|
|
||||||
return TFTPalette::Black;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t getThemeBodyFg()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].offColor;
|
|
||||||
#else
|
|
||||||
return TFTPalette::White;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isThemeFullFrameInvert()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return kThemes[resolveThemeIndex()].fullFrameInvert;
|
|
||||||
#else
|
|
||||||
return false;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t getThemeBatteryFillColor(int batteryPercent)
|
|
||||||
{
|
|
||||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
|
||||||
if (batteryPercent <= 20) {
|
|
||||||
return theme.batteryFillBad;
|
|
||||||
}
|
|
||||||
if (batteryPercent <= 50) {
|
|
||||||
return theme.batteryFillMedium;
|
|
||||||
}
|
|
||||||
return theme.batteryFillGood;
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadThemeDefaults()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
|
||||||
for (uint8_t i = 0; i < static_cast<uint8_t>(TFTColorRole::Count); i++) {
|
|
||||||
roleColors[i].onColorBe = toBe565(theme.roles[i].onColor);
|
|
||||||
roleColors[i].offColorBe = toBe565(theme.roles[i].offColor);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role color assignment with theme-aware transforms
|
|
||||||
|
|
||||||
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor)
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
const uint8_t index = static_cast<uint8_t>(role);
|
|
||||||
if (index >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F;
|
|
||||||
const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight);
|
|
||||||
const bool isBodyRole = !isHighlightRole && isBodyColorRole(role);
|
|
||||||
|
|
||||||
// Classic monochrome themes collapse all non-black accents into one tone.
|
|
||||||
if (isMonochromeTheme(themeId)) {
|
|
||||||
if (onColor != TFTPalette::Black) {
|
|
||||||
onColor = getMonochromeAccent(themeId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch (themeId) {
|
|
||||||
case ThemeID::DefaultLight:
|
|
||||||
if (isHighlightRole) {
|
|
||||||
// High-contrast highlight chips on light UI.
|
|
||||||
onColor = TFTPalette::Black;
|
|
||||||
offColor = TFTPalette::Yellow;
|
|
||||||
} else if (isBodyRole) {
|
|
||||||
// Invert body colors for readability on white frames.
|
|
||||||
if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) {
|
|
||||||
offColor = TFTPalette::White;
|
|
||||||
}
|
|
||||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ThemeID::Christmas:
|
|
||||||
if (isHighlightRole || isBodyRole) {
|
|
||||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold);
|
|
||||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ThemeID::Pink:
|
|
||||||
if (isHighlightRole) {
|
|
||||||
onColor = TFTPalette::Black;
|
|
||||||
offColor = TFTPalette::HotPink;
|
|
||||||
} else if (isBodyRole) {
|
|
||||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink);
|
|
||||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
|
||||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ThemeID::Creamsicle:
|
|
||||||
if (isHighlightRole) {
|
|
||||||
onColor = TFTPalette::Black;
|
|
||||||
offColor = TFTPalette::CreamOrange;
|
|
||||||
} else if (isBodyRole) {
|
|
||||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream);
|
|
||||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
|
||||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ThemeID::Blue:
|
|
||||||
if (isHighlightRole || isBodyRole) {
|
|
||||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue);
|
|
||||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
roleColors[index].onColorBe = toBe565(onColor);
|
|
||||||
roleColors[index].offColorBe = toBe565(offColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Region registration
|
|
||||||
|
|
||||||
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height)
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
const uint8_t roleIndex = static_cast<uint8_t>(role);
|
|
||||||
if (roleIndex >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeRegion(x, y, width, height)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TFTRoleColorsBe &colors = roleColors[roleIndex];
|
|
||||||
appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
|
|
||||||
int16_t height)
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
(void)role;
|
|
||||||
(void)onColor;
|
|
||||||
(void)offColor;
|
|
||||||
(void)x;
|
|
||||||
(void)y;
|
|
||||||
(void)width;
|
|
||||||
(void)height;
|
|
||||||
return;
|
|
||||||
#else
|
|
||||||
setTFTColorRole(role, onColor, offColor);
|
|
||||||
registerTFTColorRegion(role, x, y, width, height);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor)
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (!normalizeRegion(x, y, width, height))
|
|
||||||
return;
|
|
||||||
|
|
||||||
appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor));
|
|
||||||
}
|
|
||||||
|
|
||||||
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight)
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
(void)boxLeft;
|
|
||||||
(void)boxTop;
|
|
||||||
(void)boxWidth;
|
|
||||||
(void)boxHeight;
|
|
||||||
return;
|
|
||||||
#else
|
|
||||||
// Use theme-appropriate menu colors.
|
|
||||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
|
||||||
const TFTThemeRoleColor &menuBody = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBody)];
|
|
||||||
const TFTThemeRoleColor &menuBorder = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBorder)];
|
|
||||||
|
|
||||||
// Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly.
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1,
|
|
||||||
boxWidth + 2, boxHeight + 2);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight);
|
|
||||||
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth,
|
|
||||||
1);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frame signature & utilities
|
|
||||||
|
|
||||||
uint32_t getTFTColorFrameSignature()
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return 0;
|
|
||||||
#else
|
|
||||||
uint32_t hash = kFnv1aOffsetBasis;
|
|
||||||
hash = fnv1aAppendByte(hash, colorRegionCount);
|
|
||||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
|
||||||
const TFTColorRegion &r = colorRegions[i];
|
|
||||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.x));
|
|
||||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.y));
|
|
||||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.width));
|
|
||||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.height));
|
|
||||||
hash = fnv1aAppendU16(hash, r.onColorBe);
|
|
||||||
hash = fnv1aAppendU16(hash, r.offColorBe);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t getTFTColorRegionCount()
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return 0;
|
|
||||||
#else
|
|
||||||
return colorRegionCount;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearTFTColorRegions()
|
|
||||||
{
|
|
||||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
|
||||||
colorRegions[i].enabled = false;
|
|
||||||
}
|
|
||||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
|
||||||
colorRegions[colorRegionCount].enabled = false;
|
|
||||||
}
|
|
||||||
colorRegionCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
|
|
||||||
{
|
|
||||||
for (int i = static_cast<int>(colorRegionCount) - 1; i >= 0; i--) {
|
|
||||||
const TFTColorRegion &r = colorRegions[i];
|
|
||||||
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
|
|
||||||
return isset ? r.onColorBe : r.offColorBe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isset ? defaultOnColor : defaultOffColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor)
|
|
||||||
{
|
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
(void)x;
|
|
||||||
(void)y;
|
|
||||||
return defaultOffColor;
|
|
||||||
#else
|
|
||||||
const uint16_t defaultOffBe = toBe565(defaultOffColor);
|
|
||||||
const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe);
|
|
||||||
return static_cast<uint16_t>((sampledBe >> 8) | (sampledBe << 8));
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace graphics
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "configuration.h"
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
namespace graphics
|
|
||||||
{
|
|
||||||
|
|
||||||
struct TFTColorRegion {
|
|
||||||
int16_t x;
|
|
||||||
int16_t y;
|
|
||||||
int16_t width;
|
|
||||||
int16_t height;
|
|
||||||
uint16_t onColorBe;
|
|
||||||
uint16_t offColorBe;
|
|
||||||
// Required by ST7789 driver: it scans until the first disabled entry.
|
|
||||||
bool enabled = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
static constexpr size_t MAX_TFT_COLOR_REGIONS = 48;
|
|
||||||
extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
|
|
||||||
|
|
||||||
enum class TFTColorRole : uint8_t {
|
|
||||||
HeaderBackground = 0,
|
|
||||||
HeaderTitle,
|
|
||||||
HeaderStatus,
|
|
||||||
SignalBars,
|
|
||||||
ConnectionIcon,
|
|
||||||
UtilizationFill,
|
|
||||||
FavoriteNode,
|
|
||||||
ActionMenuBorder,
|
|
||||||
ActionMenuBody,
|
|
||||||
ActionMenuTitle,
|
|
||||||
FrameMono,
|
|
||||||
BootSplash,
|
|
||||||
FavoriteNodeBGHighlight,
|
|
||||||
NavigationBar,
|
|
||||||
NavigationArrow,
|
|
||||||
Count
|
|
||||||
};
|
|
||||||
|
|
||||||
#if HAS_TFT || defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \
|
|
||||||
defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) || \
|
|
||||||
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)
|
|
||||||
#define GRAPHICS_TFT_COLORING_ENABLED 1
|
|
||||||
#else
|
|
||||||
#define GRAPHICS_TFT_COLORING_ENABLED 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static constexpr bool kTFTColoringEnabled = GRAPHICS_TFT_COLORING_ENABLED != 0;
|
|
||||||
constexpr bool isTFTColoringEnabled()
|
|
||||||
{
|
|
||||||
return kTFTColoringEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor);
|
|
||||||
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height);
|
|
||||||
// Convenience helper for the common "set role then register one region" flow.
|
|
||||||
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
|
|
||||||
int16_t height);
|
|
||||||
// Register a region using explicit colors (no role lookup). Use when the
|
|
||||||
// color comes from a theme field rather than a role (e.g. battery fill).
|
|
||||||
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor);
|
|
||||||
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight);
|
|
||||||
uint32_t getTFTColorFrameSignature();
|
|
||||||
uint8_t getTFTColorRegionCount();
|
|
||||||
void clearTFTColorRegions();
|
|
||||||
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor);
|
|
||||||
// Resolve effective region-mapped OFF color at a coordinate in native-endian RGB565.
|
|
||||||
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor);
|
|
||||||
|
|
||||||
// -- Theme engine ------------------------------------------------------
|
|
||||||
// Each theme has four fields that work together:
|
|
||||||
//
|
|
||||||
// id - ThemeID:: constant, used for in-code references.
|
|
||||||
// name - human-readable label shown in the theme picker.
|
|
||||||
// uniqueIdentifier - the stable numeric value persisted to
|
|
||||||
// uiconfig.screen_rgb_color and restored at boot.
|
|
||||||
// This is a CONTRACT with saved configs on disk - once
|
|
||||||
// assigned, never reuse or renumber, even if the theme is
|
|
||||||
// deleted or the kThemes[] array is reordered.
|
|
||||||
// visible - controls whether a theme appears in the picker menu.
|
|
||||||
// Hidden themes can still be restored and applied if their
|
|
||||||
// uniqueIdentifier is persisted.
|
|
||||||
//
|
|
||||||
// Display order in the menu is controlled by kThemes[] array order among
|
|
||||||
// themes where visible == true, NOT by any numeric value above.
|
|
||||||
//
|
|
||||||
// To add a new theme:
|
|
||||||
// 1. Add a unique constant in ThemeID below (next unused value).
|
|
||||||
// 2. Add a kThemes[] entry at the desired menu position, with a unique
|
|
||||||
// uniqueIdentifier that has never been used by any prior theme.
|
|
||||||
// 3. Set visible=true if it should appear in the picker.
|
|
||||||
//
|
|
||||||
// To retire a theme without breaking saved configs:
|
|
||||||
// - Preferred: keep the entry and set visible=false so existing saved
|
|
||||||
// uniqueIdentifier values still resolve to the same theme.
|
|
||||||
// - If you remove the entry, resolveThemeIndex() falls back to DefaultDark
|
|
||||||
// when the persisted uniqueIdentifier no longer matches any theme.
|
|
||||||
// - Do NOT reuse a retired uniqueIdentifier for a future theme.
|
|
||||||
namespace ThemeID
|
|
||||||
{
|
|
||||||
constexpr uint32_t DefaultDark = 0;
|
|
||||||
constexpr uint32_t DefaultLight = 1;
|
|
||||||
constexpr uint32_t Christmas = 2;
|
|
||||||
constexpr uint32_t Pink = 3;
|
|
||||||
constexpr uint32_t Blue = 4;
|
|
||||||
constexpr uint32_t Creamsicle = 5;
|
|
||||||
constexpr uint32_t MeshtasticGreen = 6;
|
|
||||||
constexpr uint32_t ClassicRed = 7;
|
|
||||||
constexpr uint32_t MonochromeWhite = 8;
|
|
||||||
} // namespace ThemeID
|
|
||||||
|
|
||||||
// Per-role color pair stored in native (little-endian) RGB565 format.
|
|
||||||
struct TFTThemeRoleColor {
|
|
||||||
uint16_t onColor;
|
|
||||||
uint16_t offColor;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Complete theme definition.
|
|
||||||
struct TFTThemeDef {
|
|
||||||
uint32_t id; // ThemeID constant - in-code identifier for this theme.
|
|
||||||
const char *name; // Human-readable label shown in the theme picker.
|
|
||||||
uint32_t uniqueIdentifier; // Stable persisted value copied into uiconfig.screen_rgb_color.
|
|
||||||
// Never reuse or renumber - see file-level notes above.
|
|
||||||
TFTThemeRoleColor roles[static_cast<size_t>(TFTColorRole::Count)];
|
|
||||||
uint16_t batteryFillGood;
|
|
||||||
uint16_t batteryFillMedium;
|
|
||||||
uint16_t batteryFillBad;
|
|
||||||
bool fullFrameInvert; // Apply full-frame FrameMono inversion (ST7789 light themes)
|
|
||||||
bool visible; // Show in the theme picker menu. Hidden themes still apply
|
|
||||||
// correctly if their uniqueIdentifier is persisted (dev/legacy themes).
|
|
||||||
};
|
|
||||||
|
|
||||||
// Count of themes whose .visible flag is true. Use this when building menus.
|
|
||||||
size_t getVisibleThemeCount();
|
|
||||||
|
|
||||||
// Access the Nth visible theme (0 .. getVisibleThemeCount()-1). Hidden themes
|
|
||||||
// are skipped, preserving kThemes[] order among the visible entries.
|
|
||||||
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex);
|
|
||||||
|
|
||||||
// Return the theme that matches uiconfig.screen_rgb_color (falls back to Dark).
|
|
||||||
const TFTThemeDef &getActiveTheme();
|
|
||||||
|
|
||||||
// Return the visible-theme index for the currently active theme, or SIZE_MAX
|
|
||||||
// if the active theme is hidden (so menus can show "no selection").
|
|
||||||
size_t getActiveVisibleThemeIndex();
|
|
||||||
|
|
||||||
// Convenience accessors - safe to call even when coloring is compiled out.
|
|
||||||
uint16_t getThemeHeaderBg();
|
|
||||||
uint16_t getThemeHeaderText();
|
|
||||||
uint16_t getThemeHeaderStatus();
|
|
||||||
uint16_t getThemeBodyBg();
|
|
||||||
uint16_t getThemeBodyFg();
|
|
||||||
bool isThemeFullFrameInvert();
|
|
||||||
uint16_t getThemeBatteryFillColor(int batteryPercent);
|
|
||||||
|
|
||||||
// Reinitialise default roleColors from the active theme. Call after a
|
|
||||||
// theme change so that any role registered without a prior setTFTColorRole()
|
|
||||||
// picks up theme-appropriate defaults.
|
|
||||||
void loadThemeDefaults();
|
|
||||||
|
|
||||||
} // namespace graphics
|
|
||||||
+38
-145
@@ -16,6 +16,12 @@
|
|||||||
extern SX1509 gpioExtender;
|
extern SX1509 gpioExtender;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#ifdef TFT_MESH_OVERRIDE
|
||||||
|
uint16_t TFT_MESH = TFT_MESH_OVERRIDE;
|
||||||
|
#else
|
||||||
|
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(ST7735S)
|
#if defined(ST7735S)
|
||||||
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
|
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
|
||||||
|
|
||||||
@@ -1145,9 +1151,7 @@ static LGFX *tft = nullptr;
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include "SPILock.h"
|
#include "SPILock.h"
|
||||||
#include "TFTColorRegions.h"
|
|
||||||
#include "TFTDisplay.h"
|
#include "TFTDisplay.h"
|
||||||
#include "TFTPalette.h"
|
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
|
|
||||||
#ifdef UNPHONE
|
#ifdef UNPHONE
|
||||||
@@ -1157,25 +1161,6 @@ extern unPhone unphone;
|
|||||||
|
|
||||||
GpioPin *TFTDisplay::backlightEnable = NULL;
|
GpioPin *TFTDisplay::backlightEnable = NULL;
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
static constexpr uint8_t kFullRepaintChunkRows = 8;
|
|
||||||
|
|
||||||
static inline uint16_t getThemeDefaultOnColor()
|
|
||||||
{
|
|
||||||
return graphics::TFTPalette::White;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline uint16_t getThemeDefaultOffColor()
|
|
||||||
{
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
return graphics::getThemeBodyBg();
|
|
||||||
#else
|
|
||||||
return TFT_BLACK;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
|
TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("TFTDisplay!");
|
LOG_DEBUG("TFTDisplay!");
|
||||||
@@ -1215,15 +1200,14 @@ TFTDisplay::~TFTDisplay()
|
|||||||
free(linePixelBuffer);
|
free(linePixelBuffer);
|
||||||
linePixelBuffer = nullptr;
|
linePixelBuffer = nullptr;
|
||||||
}
|
}
|
||||||
if (repaintChunkBuffer != nullptr) {
|
|
||||||
free(repaintChunkBuffer);
|
|
||||||
repaintChunkBuffer = nullptr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the buffer to the display memory
|
// Write the buffer to the display memory
|
||||||
void TFTDisplay::display(bool fromBlank)
|
void TFTDisplay::display(bool fromBlank)
|
||||||
{
|
{
|
||||||
|
if (fromBlank)
|
||||||
|
tft->fillScreen(TFT_BLACK);
|
||||||
|
|
||||||
concurrency::LockGuard g(spiLock);
|
concurrency::LockGuard g(spiLock);
|
||||||
|
|
||||||
uint32_t x, y;
|
uint32_t x, y;
|
||||||
@@ -1232,70 +1216,12 @@ void TFTDisplay::display(bool fromBlank)
|
|||||||
uint32_t x_FirstPixelUpdate;
|
uint32_t x_FirstPixelUpdate;
|
||||||
uint32_t x_LastPixelUpdate;
|
uint32_t x_LastPixelUpdate;
|
||||||
bool isset, dblbuf_isset;
|
bool isset, dblbuf_isset;
|
||||||
uint16_t colorTftWhite, colorTftBlack;
|
uint16_t colorTftMesh, colorTftBlack;
|
||||||
bool somethingChanged = false;
|
bool somethingChanged = false;
|
||||||
|
|
||||||
// Theme defaults for non-role pixels.
|
// Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step
|
||||||
const uint16_t defaultOnColor = getThemeDefaultOnColor();
|
colorTftMesh = __builtin_bswap16(TFT_MESH);
|
||||||
const uint16_t defaultOffColor = getThemeDefaultOffColor();
|
colorTftBlack = __builtin_bswap16(TFT_BLACK);
|
||||||
static uint16_t lastDefaultOnColor = 0;
|
|
||||||
static uint16_t lastDefaultOffColor = 0;
|
|
||||||
static bool haveLastDefaults = false;
|
|
||||||
const bool themeDefaultsChanged =
|
|
||||||
!haveLastDefaults || (defaultOnColor != lastDefaultOnColor) || (defaultOffColor != lastDefaultOffColor);
|
|
||||||
const bool forceFullRepaint = fromBlank || themeDefaultsChanged;
|
|
||||||
|
|
||||||
// If theme defaults changed, reset panel background immediately so stale pixels don't linger.
|
|
||||||
if (forceFullRepaint) {
|
|
||||||
tft->fillScreen(defaultOffColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
colorTftWhite = (defaultOnColor >> 8) | ((defaultOnColor & 0xFF) << 8);
|
|
||||||
colorTftBlack = (defaultOffColor >> 8) | ((defaultOffColor & 0xFF) << 8);
|
|
||||||
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
static uint32_t lastColorFrameSignature = 0;
|
|
||||||
const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
|
|
||||||
const uint32_t colorFrameSignature = graphics::getTFTColorFrameSignature();
|
|
||||||
const bool forceFullColorRepaint = forceFullRepaint || (colorFrameSignature != lastColorFrameSignature);
|
|
||||||
|
|
||||||
// When region roles/layout changed, color can differ even with identical monochrome glyph bits.
|
|
||||||
// Repaint full frame only for those frames, then return to diff-based updates.
|
|
||||||
if (forceFullColorRepaint) {
|
|
||||||
for (uint32_t yStart = 0; yStart < displayHeight; yStart += kFullRepaintChunkRows) {
|
|
||||||
const uint32_t rowsThisChunk = min<uint32_t>(kFullRepaintChunkRows, displayHeight - yStart);
|
|
||||||
for (uint32_t row = 0; row < rowsThisChunk; row++) {
|
|
||||||
y = yStart + row;
|
|
||||||
y_byteIndex = (y / 8) * displayWidth;
|
|
||||||
y_byteMask = (1 << (y & 7));
|
|
||||||
|
|
||||||
uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth);
|
|
||||||
for (x = 0; x < displayWidth; x++) {
|
|
||||||
isset = (buffer[x + y_byteIndex] & y_byteMask) != 0;
|
|
||||||
if (hasColorRegions) {
|
|
||||||
chunkRow[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
|
|
||||||
colorTftWhite, colorTftBlack);
|
|
||||||
} else {
|
|
||||||
chunkRow[x] = isset ? colorTftWhite : colorTftBlack;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if defined(HACKADAY_COMMUNICATOR)
|
|
||||||
tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk);
|
|
||||||
#else
|
|
||||||
tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
memcpy(buffer_back, buffer, displayBufferSize);
|
|
||||||
lastColorFrameSignature = colorFrameSignature;
|
|
||||||
haveLastDefaults = true;
|
|
||||||
lastDefaultOnColor = defaultOnColor;
|
|
||||||
lastDefaultOffColor = defaultOffColor;
|
|
||||||
graphics::clearTFTColorRegions();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
y = 0;
|
y = 0;
|
||||||
while (y < displayHeight) {
|
while (y < displayHeight) {
|
||||||
@@ -1304,7 +1230,7 @@ void TFTDisplay::display(bool fromBlank)
|
|||||||
|
|
||||||
// Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas.
|
// Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas.
|
||||||
if (y_byteMask == 1) {
|
if (y_byteMask == 1) {
|
||||||
if (!forceFullRepaint) {
|
if (!fromBlank) {
|
||||||
for (x = 0; x < displayWidth; x++) {
|
for (x = 0; x < displayWidth; x++) {
|
||||||
if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex])
|
if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex])
|
||||||
break;
|
break;
|
||||||
@@ -1322,14 +1248,13 @@ void TFTDisplay::display(bool fromBlank)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Scan this row for changed span (first and last changed pixel).
|
// Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating
|
||||||
uint32_t x_FirstChanged = 0;
|
for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) {
|
||||||
for (x_FirstChanged = 0; x_FirstChanged < displayWidth; x_FirstChanged++) {
|
isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
|
||||||
isset = buffer[x_FirstChanged + y_byteIndex] & y_byteMask;
|
|
||||||
|
|
||||||
if (!forceFullRepaint) {
|
if (!fromBlank) {
|
||||||
// get src pixel in the page based ordering the OLED lib uses
|
// get src pixel in the page based ordering the OLED lib uses
|
||||||
dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask;
|
dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
|
||||||
if (isset != dblbuf_isset) {
|
if (isset != dblbuf_isset) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1339,42 +1264,26 @@ void TFTDisplay::display(bool fromBlank)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Did we find a pixel that needs updating on this row?
|
// Did we find a pixel that needs updating on this row?
|
||||||
if (x_FirstChanged < displayWidth) {
|
if (x_FirstPixelUpdate < displayWidth) {
|
||||||
uint32_t x_LastChanged = displayWidth - 1;
|
|
||||||
while (x_LastChanged > x_FirstChanged) {
|
// Quickly write out the first changed pixel (saves another array lookup)
|
||||||
isset = buffer[x_LastChanged + y_byteIndex] & y_byteMask;
|
linePixelBuffer[x_FirstPixelUpdate] = isset ? colorTftMesh : colorTftBlack;
|
||||||
if (!forceFullRepaint) {
|
x_LastPixelUpdate = x_FirstPixelUpdate;
|
||||||
dblbuf_isset = buffer_back[x_LastChanged + y_byteIndex] & y_byteMask;
|
|
||||||
|
// Step 3: copy all remaining pixels in this row into the pixel line buffer,
|
||||||
|
// while also recording the last pixel in the row that needs updating
|
||||||
|
for (x = x_FirstPixelUpdate + 1; x < displayWidth; x++) {
|
||||||
|
isset = buffer[x + y_byteIndex] & y_byteMask;
|
||||||
|
linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack;
|
||||||
|
|
||||||
|
if (!fromBlank) {
|
||||||
|
dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask;
|
||||||
if (isset != dblbuf_isset) {
|
if (isset != dblbuf_isset) {
|
||||||
break;
|
x_LastPixelUpdate = x;
|
||||||
}
|
}
|
||||||
} else if (isset) {
|
} else if (isset) {
|
||||||
break;
|
x_LastPixelUpdate = x;
|
||||||
}
|
}
|
||||||
x_LastChanged--;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Align the first pixel for update to an even number so the total alignment of
|
|
||||||
// the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
|
|
||||||
x_FirstPixelUpdate = x_FirstChanged & ~1U;
|
|
||||||
x_LastPixelUpdate = x_LastChanged | 1U;
|
|
||||||
if (x_LastPixelUpdate >= displayWidth) {
|
|
||||||
x_LastPixelUpdate = displayWidth - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Copy only the changed span into the pixel line buffer.
|
|
||||||
for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
|
|
||||||
isset = buffer[x + y_byteIndex] & y_byteMask;
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (hasColorRegions) {
|
|
||||||
linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
|
|
||||||
colorTftWhite, colorTftBlack);
|
|
||||||
} else {
|
|
||||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
#if defined(HACKADAY_COMMUNICATOR)
|
#if defined(HACKADAY_COMMUNICATOR)
|
||||||
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
|
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
|
||||||
@@ -1382,8 +1291,8 @@ void TFTDisplay::display(bool fromBlank)
|
|||||||
#else
|
#else
|
||||||
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
|
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
|
||||||
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
|
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
|
||||||
tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||||
#endif
|
#endif
|
||||||
somethingChanged = true;
|
somethingChanged = true;
|
||||||
}
|
}
|
||||||
@@ -1392,14 +1301,6 @@ void TFTDisplay::display(bool fromBlank)
|
|||||||
// Copy the Buffer to the Back Buffer
|
// Copy the Buffer to the Back Buffer
|
||||||
if (somethingChanged)
|
if (somethingChanged)
|
||||||
memcpy(buffer_back, buffer, displayBufferSize);
|
memcpy(buffer_back, buffer, displayBufferSize);
|
||||||
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
lastColorFrameSignature = colorFrameSignature;
|
|
||||||
#endif
|
|
||||||
haveLastDefaults = true;
|
|
||||||
lastDefaultOnColor = defaultOnColor;
|
|
||||||
lastDefaultOffColor = defaultOffColor;
|
|
||||||
graphics::clearTFTColorRegions();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TFTDisplay::sdlLoop()
|
void TFTDisplay::sdlLoop()
|
||||||
@@ -1613,7 +1514,7 @@ bool TFTDisplay::connect()
|
|||||||
#else
|
#else
|
||||||
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
|
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
|
||||||
#endif
|
#endif
|
||||||
tft->fillScreen(getThemeDefaultOffColor());
|
tft->fillScreen(TFT_BLACK);
|
||||||
|
|
||||||
if (this->linePixelBuffer == NULL) {
|
if (this->linePixelBuffer == NULL) {
|
||||||
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
|
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
|
||||||
@@ -1623,14 +1524,6 @@ bool TFTDisplay::connect()
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this->repaintChunkBuffer == NULL) {
|
|
||||||
this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows);
|
|
||||||
|
|
||||||
if (!this->repaintChunkBuffer) {
|
|
||||||
LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,5 +63,4 @@ class TFTDisplay : public OLEDDisplay
|
|||||||
virtual bool connect() override;
|
virtual bool connect() override;
|
||||||
|
|
||||||
uint16_t *linePixelBuffer = nullptr;
|
uint16_t *linePixelBuffer = nullptr;
|
||||||
uint16_t *repaintChunkBuffer = nullptr;
|
};
|
||||||
};
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
namespace graphics
|
|
||||||
{
|
|
||||||
namespace TFTPalette
|
|
||||||
{
|
|
||||||
|
|
||||||
constexpr uint16_t rgb565(uint8_t red, uint8_t green, uint8_t blue)
|
|
||||||
{
|
|
||||||
return static_cast<uint16_t>(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | ((blue & 0xF8) >> 3));
|
|
||||||
}
|
|
||||||
|
|
||||||
constexpr uint16_t Black = 0x0000;
|
|
||||||
constexpr uint16_t White = 0xFFFF;
|
|
||||||
constexpr uint16_t DarkGray = 0x4208;
|
|
||||||
constexpr uint16_t Gray = 0x8410;
|
|
||||||
constexpr uint16_t LightGray = 0xC618;
|
|
||||||
|
|
||||||
constexpr uint16_t Red = rgb565(255, 0, 0);
|
|
||||||
constexpr uint16_t Green = rgb565(0, 255, 0);
|
|
||||||
constexpr uint16_t Blue = rgb565(0, 130, 252);
|
|
||||||
constexpr uint16_t Yellow = rgb565(255, 255, 0);
|
|
||||||
constexpr uint16_t Orange = rgb565(255, 165, 0);
|
|
||||||
constexpr uint16_t Cyan = rgb565(0, 255, 255);
|
|
||||||
constexpr uint16_t Magenta = rgb565(255, 0, 255);
|
|
||||||
|
|
||||||
constexpr uint16_t Good = Green;
|
|
||||||
constexpr uint16_t Medium = Yellow;
|
|
||||||
constexpr uint16_t Bad = Red;
|
|
||||||
|
|
||||||
// Christmas / seasonal accent colors
|
|
||||||
constexpr uint16_t ChristmasRed = rgb565(178, 34, 34);
|
|
||||||
constexpr uint16_t ChristmasGreen = rgb565(0, 128, 0);
|
|
||||||
constexpr uint16_t Gold = rgb565(255, 215, 0);
|
|
||||||
constexpr uint16_t Pine = rgb565(15, 35, 10);
|
|
||||||
|
|
||||||
// Pink theme colors (light variant)
|
|
||||||
constexpr uint16_t HotPink = rgb565(255, 105, 180);
|
|
||||||
constexpr uint16_t PalePink = rgb565(255, 228, 235);
|
|
||||||
constexpr uint16_t DeepPink = rgb565(200, 50, 120);
|
|
||||||
|
|
||||||
// Blue theme colors (dark variant)
|
|
||||||
constexpr uint16_t SkyBlue = rgb565(100, 180, 255);
|
|
||||||
constexpr uint16_t Navy = rgb565(15, 15, 50);
|
|
||||||
constexpr uint16_t DeepBlue = rgb565(30, 60, 120);
|
|
||||||
|
|
||||||
// Creamsicle theme colors (light variant)
|
|
||||||
constexpr uint16_t CreamOrange = rgb565(255, 140, 50);
|
|
||||||
constexpr uint16_t DeepOrange = rgb565(220, 100, 20);
|
|
||||||
constexpr uint16_t Cream = rgb565(255, 248, 235);
|
|
||||||
|
|
||||||
// Classic monochrome theme accent colors (single-color-on-black themes)
|
|
||||||
constexpr uint16_t MeshtasticGreen = rgb565(0x67, 0xEA, 0x94);
|
|
||||||
constexpr uint16_t ClassicRed = rgb565(255, 64, 64);
|
|
||||||
// Monochrome White reuses TFTPalette::White above.
|
|
||||||
|
|
||||||
// Fast contrast picker for monochrome glyph overlays on arbitrary RGB565 backgrounds.
|
|
||||||
// Uses channel-sum brightness approximation to keep code size small.
|
|
||||||
constexpr uint16_t pickReadableMonoFg(uint16_t backgroundColor)
|
|
||||||
{
|
|
||||||
const uint16_t r = (backgroundColor >> 11) & 0x1F;
|
|
||||||
const uint16_t g = (backgroundColor >> 5) & 0x3F;
|
|
||||||
const uint16_t b = backgroundColor & 0x1F;
|
|
||||||
return ((r + g + b) >= 70) ? DarkGray : White;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace TFTPalette
|
|
||||||
} // namespace graphics
|
|
||||||
@@ -145,7 +145,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
|||||||
// === Set Title, Blank for Clock
|
// === Set Title, Blank for Clock
|
||||||
const char *titleStr = "";
|
const char *titleStr = "";
|
||||||
// === Header ===
|
// === Header ===
|
||||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
|
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
|
||||||
|
|
||||||
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
|
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
|
||||||
char timeString[16];
|
char timeString[16];
|
||||||
@@ -293,15 +293,11 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
|||||||
// Draw an analog clock
|
// Draw an analog clock
|
||||||
void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||||
{
|
{
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
// Clear previous frame pixels so moving hands don't leave stale artifacts on TFT light theme.
|
|
||||||
display->clear();
|
|
||||||
#endif
|
|
||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
// === Set Title, Blank for Clock
|
// === Set Title, Blank for Clock
|
||||||
const char *titleStr = "";
|
const char *titleStr = "";
|
||||||
// === Header ===
|
// === Header ===
|
||||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
|
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
|
||||||
|
|
||||||
// clock face center coordinates
|
// clock face center coordinates
|
||||||
int16_t centerX = display->getWidth() / 2;
|
int16_t centerX = display->getWidth() / 2;
|
||||||
@@ -482,4 +478,4 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
} // namespace ClockRenderer
|
} // namespace ClockRenderer
|
||||||
|
|
||||||
} // namespace graphics
|
} // namespace graphics
|
||||||
#endif
|
#endif
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#if HAS_SCREEN
|
#if HAS_SCREEN
|
||||||
#include "CompassRenderer.h"
|
#include "CompassRenderer.h"
|
||||||
|
#include "NodeDB.h"
|
||||||
|
#include "UIRenderer.h"
|
||||||
|
#include "configuration.h"
|
||||||
|
#include "gps/GeoCoord.h"
|
||||||
#include "graphics/ScreenFonts.h"
|
#include "graphics/ScreenFonts.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -9,103 +13,111 @@ namespace graphics
|
|||||||
{
|
{
|
||||||
namespace CompassRenderer
|
namespace CompassRenderer
|
||||||
{
|
{
|
||||||
|
|
||||||
|
// Point helper class for compass calculations
|
||||||
|
struct Point {
|
||||||
|
float x, y;
|
||||||
|
Point(float x, float y) : x(x), y(y) {}
|
||||||
|
|
||||||
|
void rotate(float angle)
|
||||||
|
{
|
||||||
|
float cos_a = cos(angle);
|
||||||
|
float sin_a = sin(angle);
|
||||||
|
float new_x = x * cos_a - y * sin_a;
|
||||||
|
float new_y = x * sin_a + y * cos_a;
|
||||||
|
x = new_x;
|
||||||
|
y = new_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
void scale(float factor)
|
||||||
|
{
|
||||||
|
x *= factor;
|
||||||
|
y *= factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
void translate(float dx, float dy)
|
||||||
|
{
|
||||||
|
x += dx;
|
||||||
|
y += dy;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
|
void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
|
||||||
{
|
{
|
||||||
|
// Show the compass heading (not implemented in original)
|
||||||
|
// This could draw a "N" indicator or north arrow
|
||||||
|
// For now, we'll draw a simple north indicator
|
||||||
|
// const float radius = 17.0f;
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
radius += 4;
|
radius += 4;
|
||||||
}
|
}
|
||||||
|
Point north(0, -radius);
|
||||||
const float northAngle = (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -myHeading : 0.0f;
|
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||||
const int16_t nX = compassX + static_cast<int16_t>((radius - 1) * sinf(northAngle));
|
north.rotate(-myHeading);
|
||||||
const int16_t nY = compassY - static_cast<int16_t>((radius - 1) * cosf(northAngle));
|
north.translate(compassX, compassY);
|
||||||
|
|
||||||
display->setFont(FONT_SMALL);
|
display->setFont(FONT_SMALL);
|
||||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
const int16_t nLabelWidth = display->getStringWidth("N");
|
|
||||||
if (currentResolution == ScreenResolution::High) {
|
if (currentResolution == ScreenResolution::High) {
|
||||||
display->fillRect(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
|
display->fillRect(north.x - 8, north.y - 1, display->getStringWidth("N") + 3, FONT_HEIGHT_SMALL - 6);
|
||||||
} else {
|
} else {
|
||||||
display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
|
display->fillRect(north.x - 4, north.y - 1, display->getStringWidth("N") + 2, FONT_HEIGHT_SMALL - 6);
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
display->drawString(nX, nY - 3, "N");
|
display->drawString(north.x, north.y - 3, "N");
|
||||||
}
|
|
||||||
|
|
||||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
|
|
||||||
{
|
|
||||||
const float radians = bearing * DEG_TO_RAD;
|
|
||||||
const float sinA = sinf(radians);
|
|
||||||
const float cosA = cosf(radians);
|
|
||||||
const float tipHalf = size * 0.5f;
|
|
||||||
const float lx = -(size / 6.0f);
|
|
||||||
const float ly = size / 4.0f;
|
|
||||||
const float rx = (size / 6.0f);
|
|
||||||
const float ry = size / 4.0f;
|
|
||||||
const float tx = 0.0f;
|
|
||||||
const float ty = size / 4.5f;
|
|
||||||
|
|
||||||
const int16_t tipX = static_cast<int16_t>(x + (tipHalf * sinA));
|
|
||||||
const int16_t tipY = static_cast<int16_t>(y - (tipHalf * cosA));
|
|
||||||
const int16_t leftX = static_cast<int16_t>(x + (lx * cosA) - (ly * sinA));
|
|
||||||
const int16_t leftY = static_cast<int16_t>(y + (lx * sinA) + (ly * cosA));
|
|
||||||
const int16_t rightX = static_cast<int16_t>(x + (rx * cosA) - (ry * sinA));
|
|
||||||
const int16_t rightY = static_cast<int16_t>(y + (rx * sinA) + (ry * cosA));
|
|
||||||
const int16_t tailX = static_cast<int16_t>(x + (tx * cosA) - (ty * sinA));
|
|
||||||
const int16_t tailY = static_cast<int16_t>(y + (tx * sinA) + (ty * cosA));
|
|
||||||
|
|
||||||
display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY);
|
|
||||||
display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
|
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
|
||||||
{
|
{
|
||||||
const int16_t size = static_cast<int16_t>(compassDiam * 0.6f);
|
Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially
|
||||||
drawArrowToNode(display, compassX, compassY, size, headingRadian * RAD_TO_DEG);
|
float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f;
|
||||||
}
|
Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY);
|
||||||
|
|
||||||
bool getHeadingRadians(double lat, double lon, float &headingRadian)
|
Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
|
||||||
{
|
|
||||||
headingRadian = 0.0f;
|
|
||||||
|
|
||||||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING)
|
for (int i = 0; i < 4; i++) {
|
||||||
return true;
|
arrowPoints[i]->rotate(headingRadian);
|
||||||
|
arrowPoints[i]->scale(compassDiam * 0.6);
|
||||||
if (!screen)
|
arrowPoints[i]->translate(compassX, compassY);
|
||||||
return false;
|
|
||||||
|
|
||||||
if (screen->hasHeading()) {
|
|
||||||
headingRadian = screen->getHeading() * DEG_TO_RAD;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const float estimatedHeadingDeg = screen->estimatedHeading(lat, lon);
|
#ifdef USE_EINK
|
||||||
if (!(estimatedHeadingDeg >= 0.0f))
|
display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
|
||||||
return false;
|
#else
|
||||||
|
display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
|
||||||
headingRadian = estimatedHeadingDeg * DEG_TO_RAD;
|
#endif
|
||||||
return true;
|
display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian)
|
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
|
||||||
{
|
{
|
||||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
float radians = bearing * DEG_TO_RAD;
|
||||||
return bearingRadian - headingRadian;
|
|
||||||
|
|
||||||
return bearingRadian;
|
Point tip(0, -size / 2);
|
||||||
|
Point left(-size / 6, size / 4);
|
||||||
|
Point right(size / 6, size / 4);
|
||||||
|
Point tail(0, size / 4.5);
|
||||||
|
|
||||||
|
tip.rotate(radians);
|
||||||
|
left.rotate(radians);
|
||||||
|
right.rotate(radians);
|
||||||
|
tail.rotate(radians);
|
||||||
|
|
||||||
|
tip.translate(x, y);
|
||||||
|
left.translate(x, y);
|
||||||
|
right.translate(x, y);
|
||||||
|
tail.translate(x, y);
|
||||||
|
|
||||||
|
display->fillTriangle(tip.x, tip.y, left.x, left.y, tail.x, tail.y);
|
||||||
|
display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
float radiansToDegrees360(float angleRadian)
|
float estimatedHeading(double lat, double lon)
|
||||||
{
|
{
|
||||||
constexpr float fullTurnDeg = 360.0f;
|
// Simple magnetic declination estimation
|
||||||
float degrees = angleRadian * RAD_TO_DEG;
|
// This is a very basic implementation - the original might be more sophisticated
|
||||||
if (degrees < 0.0f)
|
return 0.0f; // Return 0 for now, indicating no heading available
|
||||||
degrees += fullTurnDeg;
|
|
||||||
else if (degrees >= fullTurnDeg)
|
|
||||||
degrees -= fullTurnDeg;
|
|
||||||
return degrees;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
||||||
@@ -125,4 +137,4 @@ uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
|||||||
|
|
||||||
} // namespace CompassRenderer
|
} // namespace CompassRenderer
|
||||||
} // namespace graphics
|
} // namespace graphics
|
||||||
#endif
|
#endif
|
||||||
@@ -25,9 +25,7 @@ void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, u
|
|||||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing);
|
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing);
|
||||||
|
|
||||||
// Navigation and location functions
|
// Navigation and location functions
|
||||||
bool getHeadingRadians(double lat, double lon, float &headingRadian);
|
float estimatedHeading(double lat, double lon);
|
||||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian);
|
|
||||||
float radiansToDegrees360(float angleRadian);
|
|
||||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
|
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
|
||||||
|
|
||||||
} // namespace CompassRenderer
|
} // namespace CompassRenderer
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
#include "gps/RTC.h"
|
#include "gps/RTC.h"
|
||||||
#include "graphics/ScreenFonts.h"
|
#include "graphics/ScreenFonts.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/TFTColorRegions.h"
|
|
||||||
#include "graphics/TFTPalette.h"
|
|
||||||
#include "graphics/TimeFormatters.h"
|
#include "graphics/TimeFormatters.h"
|
||||||
#include "graphics/images.h"
|
#include "graphics/images.h"
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
@@ -410,16 +408,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
|||||||
display->drawString(nameX, getTextPositions(display)[line++], device_role);
|
display->drawString(nameX, getTextPositions(display)[line++], device_role);
|
||||||
|
|
||||||
// === Third Row: Radio Preset ===
|
// === Third Row: Radio Preset ===
|
||||||
// For custom modem settings show the actual parameters; for presets use the preset name.
|
auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
|
||||||
char modeStr[16];
|
|
||||||
if (!config.lora.use_preset) {
|
|
||||||
snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast<unsigned>(config.lora.bandwidth),
|
|
||||||
static_cast<unsigned>(config.lora.spread_factor), static_cast<unsigned>(config.lora.coding_rate));
|
|
||||||
} else {
|
|
||||||
strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true),
|
|
||||||
sizeof(modeStr) - 1);
|
|
||||||
modeStr[sizeof(modeStr) - 1] = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
char regionradiopreset[25];
|
char regionradiopreset[25];
|
||||||
const char *region = myRegion ? myRegion->name : NULL;
|
const char *region = myRegion ? myRegion->name : NULL;
|
||||||
@@ -427,7 +416,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
|||||||
if (currentResolution == ScreenResolution::UltraLow) {
|
if (currentResolution == ScreenResolution::UltraLow) {
|
||||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region);
|
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region);
|
||||||
} else {
|
} else {
|
||||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr);
|
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
textWidth = display->getStringWidth(regionradiopreset);
|
textWidth = display->getStringWidth(regionradiopreset);
|
||||||
@@ -460,7 +449,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
|||||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||||
display->drawString(nameX, getTextPositions(display)[line++], frequencyslot);
|
display->drawString(nameX, getTextPositions(display)[line++], frequencyslot);
|
||||||
|
|
||||||
#if !defined(M5STACK_UNITC6L)
|
#if !defined(OLED_TINY)
|
||||||
// === Fifth Row: Channel Utilization ===
|
// === Fifth Row: Channel Utilization ===
|
||||||
const char *chUtil = "ChUtil:";
|
const char *chUtil = "ChUtil:";
|
||||||
char chUtilPercentage[10];
|
char chUtilPercentage[10];
|
||||||
@@ -471,11 +460,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
|||||||
int chUtil_y = getTextPositions(display)[line] + 3;
|
int chUtil_y = getTextPositions(display)[line] + 3;
|
||||||
|
|
||||||
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
||||||
int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border
|
|
||||||
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
|
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
|
||||||
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
|
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
|
||||||
int chutil_percent = airTime->channelUtilizationPercent();
|
int chutil_percent = airTime->channelUtilizationPercent();
|
||||||
const int raw_chutil_percent = chutil_percent;
|
|
||||||
|
|
||||||
int centerofscreen = SCREEN_WIDTH / 2;
|
int centerofscreen = SCREEN_WIDTH / 2;
|
||||||
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
|
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
|
||||||
@@ -483,7 +470,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
|||||||
|
|
||||||
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
||||||
|
|
||||||
// Force 61% or higher to show a full 100% bar, text would still show related percent.
|
// Force 56% or higher to show a full 100% bar, text would still show related percent.
|
||||||
if (chutil_percent >= 61) {
|
if (chutil_percent >= 61) {
|
||||||
chutil_percent = 100;
|
chutil_percent = 100;
|
||||||
}
|
}
|
||||||
@@ -496,9 +483,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
|||||||
float weight3 = 0.20; // Weight for 40–100%
|
float weight3 = 0.20; // Weight for 40–100%
|
||||||
float totalWeight = weight1 + weight2 + weight3;
|
float totalWeight = weight1 + weight2 + weight3;
|
||||||
|
|
||||||
int seg1 = chutil_bar_max_fill * (weight1 / totalWeight);
|
int seg1 = chutil_bar_width * (weight1 / totalWeight);
|
||||||
int seg2 = chutil_bar_max_fill * (weight2 / totalWeight);
|
int seg2 = chutil_bar_width * (weight2 / totalWeight);
|
||||||
int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors
|
int seg3 = chutil_bar_width * (weight3 / totalWeight);
|
||||||
|
|
||||||
int fillRight = 0;
|
int fillRight = 0;
|
||||||
|
|
||||||
@@ -515,17 +502,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
|||||||
|
|
||||||
// Fill progress
|
// Fill progress
|
||||||
if (fillRight > 0) {
|
if (fillRight > 0) {
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
|
||||||
uint16_t UtilizationFillColor = TFTPalette::Good;
|
|
||||||
if (raw_chutil_percent >= 60) {
|
|
||||||
UtilizationFillColor = TFTPalette::Bad;
|
|
||||||
} else if (raw_chutil_percent >= 35) {
|
|
||||||
UtilizationFillColor = TFTPalette::Medium;
|
|
||||||
}
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black,
|
|
||||||
starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
|
||||||
#endif
|
|
||||||
display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++],
|
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++],
|
||||||
@@ -592,23 +569,12 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
|||||||
// Label
|
// Label
|
||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
display->drawString(labelX, getTextPositions(display)[line], label);
|
display->drawString(labelX, getTextPositions(display)[line], label);
|
||||||
#if !defined(M5STACK_UNITC6L)
|
#if !defined(OLED_TINY)
|
||||||
// Bar
|
// Bar
|
||||||
int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2;
|
int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2;
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
display->drawRect(barX, barY, adjustedBarWidth, barHeight);
|
display->drawRect(barX, barY, adjustedBarWidth, barHeight);
|
||||||
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
uint16_t UtilizationFillColor = TFTPalette::Good;
|
|
||||||
if (percent >= 80) {
|
|
||||||
UtilizationFillColor = TFTPalette::Bad;
|
|
||||||
} else if (percent >= 60) {
|
|
||||||
UtilizationFillColor = TFTPalette::Medium;
|
|
||||||
}
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, barX + 1, barY + 1,
|
|
||||||
fillWidth - 1, barHeight - 2);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
display->fillRect(barX, barY, fillWidth, barHeight);
|
display->fillRect(barX, barY, fillWidth, barHeight);
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
#include "buzz.h"
|
#include "buzz.h"
|
||||||
#include "graphics/Screen.h"
|
#include "graphics/Screen.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/TFTColorRegions.h"
|
|
||||||
#include "graphics/draw/MessageRenderer.h"
|
#include "graphics/draw/MessageRenderer.h"
|
||||||
#include "graphics/draw/UIRenderer.h"
|
#include "graphics/draw/UIRenderer.h"
|
||||||
#include "input/RotaryEncoderInterruptImpl1.h"
|
#include "input/RotaryEncoderInterruptImpl1.h"
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
#include "main.h"
|
#include "main.h"
|
||||||
#include "mesh/Default.h"
|
#include "mesh/Default.h"
|
||||||
#include "mesh/MeshTypes.h"
|
#include "mesh/MeshTypes.h"
|
||||||
#include "mesh/RadioLibInterface.h"
|
|
||||||
#include "modules/AdminModule.h"
|
#include "modules/AdminModule.h"
|
||||||
#include "modules/CannedMessageModule.h"
|
#include "modules/CannedMessageModule.h"
|
||||||
#include "modules/ExternalNotificationModule.h"
|
#include "modules/ExternalNotificationModule.h"
|
||||||
@@ -27,10 +25,11 @@
|
|||||||
#include "modules/TraceRouteModule.h"
|
#include "modules/TraceRouteModule.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cmath>
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
extern uint16_t TFT_MESH;
|
||||||
|
|
||||||
namespace graphics
|
namespace graphics
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -160,22 +159,31 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: without a reboot, reconfigure() applies the region directly.
|
|
||||||
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
|
|
||||||
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora
|
|
||||||
// rather than a hardcoded check for LORA_24.
|
|
||||||
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
|
|
||||||
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
|
|
||||||
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
config.lora.region = selectedRegion;
|
config.lora.region = selectedRegion;
|
||||||
auto changes = SEGMENT_CONFIG;
|
auto changes = SEGMENT_CONFIG;
|
||||||
|
|
||||||
|
// FIXME: This should be a method consolidated with the same logic in the admin message as well
|
||||||
|
// This is needed as we wait til picking the LoRa region to generate keys for the first time.
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||||
if (crypto) {
|
if (!owner.is_licensed) {
|
||||||
crypto->ensurePkiKeys(config.security, owner);
|
bool keygenSuccess = false;
|
||||||
|
if (config.security.private_key.size == 32) {
|
||||||
|
// public key is derived from private, so this will always have the same result.
|
||||||
|
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||||
|
keygenSuccess = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
LOG_INFO("Generate new PKI keys");
|
||||||
|
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||||
|
keygenSuccess = true;
|
||||||
|
}
|
||||||
|
if (keygenSuccess) {
|
||||||
|
config.security.public_key.size = 32;
|
||||||
|
config.security.private_key.size = 32;
|
||||||
|
owner.public_key.size = 32;
|
||||||
|
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
config.lora.tx_enabled = true;
|
config.lora.tx_enabled = true;
|
||||||
@@ -191,6 +199,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
|||||||
}
|
}
|
||||||
|
|
||||||
service->reloadConfig(changes);
|
service->reloadConfig(changes);
|
||||||
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
bannerOptions.durationMs = duration;
|
bannerOptions.durationMs = duration;
|
||||||
@@ -256,24 +265,13 @@ void menuHandler::FrequencySlotPicker()
|
|||||||
optionsEnumArray[options++] = 0;
|
optionsEnumArray[options++] = 0;
|
||||||
|
|
||||||
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
|
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
|
||||||
|
|
||||||
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
|
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
|
||||||
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
|
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
|
||||||
: bwCodeToKHz(loraConfig.bandwidth);
|
: bwCodeToKHz(loraConfig.bandwidth);
|
||||||
|
|
||||||
uint32_t numChannels = 0;
|
uint32_t numChannels = 0;
|
||||||
if (myRegion) {
|
if (myRegion) {
|
||||||
// Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round()
|
numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0)));
|
||||||
const double spacing = myRegion->profile->spacing;
|
|
||||||
const double padding = myRegion->profile->padding;
|
|
||||||
const double channelBandwidthMHz = bw / 1000.0;
|
|
||||||
const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing;
|
|
||||||
const double denominator = spacing + (padding * 2) + channelBandwidthMHz;
|
|
||||||
if (denominator > 0.0) {
|
|
||||||
numChannels = static_cast<uint32_t>(round(numerator / denominator));
|
|
||||||
} else {
|
|
||||||
LOG_WARN("Invalid region configuration: non-positive channel spacing/width");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LOG_WARN("Region not set, cannot calculate number of channels");
|
LOG_WARN("Region not set, cannot calculate number of channels");
|
||||||
return;
|
return;
|
||||||
@@ -309,6 +307,7 @@ void menuHandler::FrequencySlotPicker()
|
|||||||
|
|
||||||
config.lora.channel_num = selected;
|
config.lora.channel_num = selected;
|
||||||
service->reloadConfig(SEGMENT_CONFIG);
|
service->reloadConfig(SEGMENT_CONFIG);
|
||||||
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
screen->showOverlayBanner(bannerOptions);
|
screen->showOverlayBanner(bannerOptions);
|
||||||
@@ -347,6 +346,7 @@ void menuHandler::radioPresetPicker()
|
|||||||
config.lora.channel_num = 0; // Reset to default channel for the preset
|
config.lora.channel_num = 0; // Reset to default channel for the preset
|
||||||
config.lora.override_frequency = 0; // Clear any custom frequency
|
config.lora.override_frequency = 0; // Clear any custom frequency
|
||||||
service->reloadConfig(SEGMENT_CONFIG);
|
service->reloadConfig(SEGMENT_CONFIG);
|
||||||
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen->showOverlayBanner(bannerOptions);
|
screen->showOverlayBanner(bannerOptions);
|
||||||
@@ -491,7 +491,7 @@ void menuHandler::TZPicker()
|
|||||||
|
|
||||||
void menuHandler::clockMenu()
|
void menuHandler::clockMenu()
|
||||||
{
|
{
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
static const char *optionsArray[] = {"Back", "Time Format", "Timezone"};
|
static const char *optionsArray[] = {"Back", "Time Format", "Timezone"};
|
||||||
#else
|
#else
|
||||||
static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"};
|
static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"};
|
||||||
@@ -2027,6 +2027,109 @@ void menuHandler::switchToMUIMenu()
|
|||||||
screen->showOverlayBanner(bannerOptions);
|
screen->showOverlayBanner(bannerOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void menuHandler::TFTColorPickerMenu(OLEDDisplay *display)
|
||||||
|
{
|
||||||
|
static const ScreenColorOption colorOptions[] = {
|
||||||
|
{"Back", OptionsAction::Back},
|
||||||
|
{"Default", OptionsAction::Select, ScreenColor(0, 0, 0, true)},
|
||||||
|
{"Meshtastic Green", OptionsAction::Select, ScreenColor(0x67, 0xEA, 0x94)},
|
||||||
|
{"Yellow", OptionsAction::Select, ScreenColor(255, 255, 128)},
|
||||||
|
{"Red", OptionsAction::Select, ScreenColor(255, 64, 64)},
|
||||||
|
{"Orange", OptionsAction::Select, ScreenColor(255, 160, 20)},
|
||||||
|
{"Purple", OptionsAction::Select, ScreenColor(204, 153, 255)},
|
||||||
|
{"Blue", OptionsAction::Select, ScreenColor(0, 0, 255)},
|
||||||
|
{"Teal", OptionsAction::Select, ScreenColor(16, 102, 102)},
|
||||||
|
{"Cyan", OptionsAction::Select, ScreenColor(0, 255, 255)},
|
||||||
|
{"Ice", OptionsAction::Select, ScreenColor(173, 216, 230)},
|
||||||
|
{"Pink", OptionsAction::Select, ScreenColor(255, 105, 180)},
|
||||||
|
{"White", OptionsAction::Select, ScreenColor(255, 255, 255)},
|
||||||
|
{"Gray", OptionsAction::Select, ScreenColor(128, 128, 128)},
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr size_t colorCount = sizeof(colorOptions) / sizeof(colorOptions[0]);
|
||||||
|
static std::array<const char *, colorCount> colorLabels{};
|
||||||
|
|
||||||
|
auto bannerOptions = createStaticBannerOptions(
|
||||||
|
"Select Screen Color", colorOptions, colorLabels, [display](const ScreenColorOption &option, int) -> void {
|
||||||
|
if (option.action == OptionsAction::Back) {
|
||||||
|
menuQueue = SystemBaseMenu;
|
||||||
|
screen->runNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!option.hasValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
|
||||||
|
HAS_TFT || defined(HACKADAY_COMMUNICATOR)
|
||||||
|
const ScreenColor &color = option.value;
|
||||||
|
if (color.useVariant) {
|
||||||
|
LOG_INFO("Setting color to system default or defined variant");
|
||||||
|
} else {
|
||||||
|
LOG_INFO("Setting color to %s", option.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t r = color.r;
|
||||||
|
uint8_t g = color.g;
|
||||||
|
uint8_t b = color.b;
|
||||||
|
|
||||||
|
display->setColor(BLACK);
|
||||||
|
display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||||
|
display->setColor(WHITE);
|
||||||
|
|
||||||
|
if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
|
||||||
|
#ifdef TFT_MESH_OVERRIDE
|
||||||
|
TFT_MESH = TFT_MESH_OVERRIDE;
|
||||||
|
#else
|
||||||
|
TFT_MESH = COLOR565(255, 255, 128);
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
TFT_MESH = COLOR565(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190)
|
||||||
|
static_cast<ST7789Spi *>(screen->getDisplayDevice())->setRGB(TFT_MESH);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
screen->setFrames(graphics::Screen::FOCUS_SYSTEM);
|
||||||
|
if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
|
||||||
|
uiconfig.screen_rgb_color = 0;
|
||||||
|
} else {
|
||||||
|
uiconfig.screen_rgb_color =
|
||||||
|
(static_cast<uint32_t>(r) << 16) | (static_cast<uint32_t>(g) << 8) | static_cast<uint32_t>(b);
|
||||||
|
}
|
||||||
|
LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color);
|
||||||
|
saveUIConfig();
|
||||||
|
#endif
|
||||||
|
});
|
||||||
|
|
||||||
|
int initialSelection = 0;
|
||||||
|
if (uiconfig.screen_rgb_color == 0) {
|
||||||
|
initialSelection = 1;
|
||||||
|
} else {
|
||||||
|
uint32_t currentColor = uiconfig.screen_rgb_color;
|
||||||
|
for (size_t i = 0; i < colorCount; ++i) {
|
||||||
|
if (!colorOptions[i].hasValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ScreenColor &color = colorOptions[i].value;
|
||||||
|
if (color.useVariant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uint32_t encoded =
|
||||||
|
(static_cast<uint32_t>(color.r) << 16) | (static_cast<uint32_t>(color.g) << 8) | static_cast<uint32_t>(color.b);
|
||||||
|
if (encoded == currentColor) {
|
||||||
|
initialSelection = static_cast<int>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bannerOptions.InitialSelected = initialSelection;
|
||||||
|
|
||||||
|
screen->showOverlayBanner(bannerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
void menuHandler::rebootMenu()
|
void menuHandler::rebootMenu()
|
||||||
{
|
{
|
||||||
static const char *optionsArray[] = {"Back", "Confirm"};
|
static const char *optionsArray[] = {"Back", "Confirm"};
|
||||||
@@ -2214,9 +2317,9 @@ void menuHandler::screenOptionsMenu()
|
|||||||
bool hasSupportBrightness = false;
|
bool hasSupportBrightness = false;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme };
|
enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles };
|
||||||
static const char *optionsArray[7] = {"Back"};
|
static const char *optionsArray[6] = {"Back"};
|
||||||
static int optionsEnumArray[7] = {Back};
|
static int optionsEnumArray[6] = {Back};
|
||||||
int options = 1;
|
int options = 1;
|
||||||
|
|
||||||
// Only show brightness for B&W displays
|
// Only show brightness for B&W displays
|
||||||
@@ -2225,6 +2328,13 @@ void menuHandler::screenOptionsMenu()
|
|||||||
optionsEnumArray[options++] = Brightness;
|
optionsEnumArray[options++] = Brightness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show screen color for TFT displays
|
||||||
|
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
|
||||||
|
HAS_TFT || defined(HACKADAY_COMMUNICATOR)
|
||||||
|
optionsArray[options] = "Screen Color";
|
||||||
|
optionsEnumArray[options++] = ScreenColor;
|
||||||
|
#endif
|
||||||
|
|
||||||
optionsArray[options] = "Frame Visibility";
|
optionsArray[options] = "Frame Visibility";
|
||||||
optionsEnumArray[options++] = FrameToggles;
|
optionsEnumArray[options++] = FrameToggles;
|
||||||
|
|
||||||
@@ -2234,11 +2344,6 @@ void menuHandler::screenOptionsMenu()
|
|||||||
optionsArray[options] = "Message Bubbles";
|
optionsArray[options] = "Message Bubbles";
|
||||||
optionsEnumArray[options++] = MessageBubbles;
|
optionsEnumArray[options++] = MessageBubbles;
|
||||||
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
optionsArray[options] = "Theme";
|
|
||||||
optionsEnumArray[options++] = Theme;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
BannerOverlayOptions bannerOptions;
|
BannerOverlayOptions bannerOptions;
|
||||||
bannerOptions.message = "Display Options";
|
bannerOptions.message = "Display Options";
|
||||||
bannerOptions.optionsArrayPtr = optionsArray;
|
bannerOptions.optionsArrayPtr = optionsArray;
|
||||||
@@ -2248,6 +2353,9 @@ void menuHandler::screenOptionsMenu()
|
|||||||
if (selected == Brightness) {
|
if (selected == Brightness) {
|
||||||
menuHandler::menuQueue = menuHandler::BrightnessPicker;
|
menuHandler::menuQueue = menuHandler::BrightnessPicker;
|
||||||
screen->runNow();
|
screen->runNow();
|
||||||
|
} else if (selected == ScreenColor) {
|
||||||
|
menuHandler::menuQueue = menuHandler::TftColorMenuPicker;
|
||||||
|
screen->runNow();
|
||||||
} else if (selected == FrameToggles) {
|
} else if (selected == FrameToggles) {
|
||||||
menuHandler::menuQueue = menuHandler::FrameToggles;
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
||||||
screen->runNow();
|
screen->runNow();
|
||||||
@@ -2257,9 +2365,6 @@ void menuHandler::screenOptionsMenu()
|
|||||||
} else if (selected == MessageBubbles) {
|
} else if (selected == MessageBubbles) {
|
||||||
menuHandler::menuQueue = menuHandler::MessageBubblesMenu;
|
menuHandler::menuQueue = menuHandler::MessageBubblesMenu;
|
||||||
screen->runNow();
|
screen->runNow();
|
||||||
} else if (selected == Theme) {
|
|
||||||
menuHandler::menuQueue = menuHandler::ThemeMenu;
|
|
||||||
screen->runNow();
|
|
||||||
} else {
|
} else {
|
||||||
menuQueue = SystemBaseMenu;
|
menuQueue = SystemBaseMenu;
|
||||||
screen->runNow();
|
screen->runNow();
|
||||||
@@ -2543,53 +2648,6 @@ void menuHandler::messageBubblesMenu()
|
|||||||
screen->showOverlayBanner(bannerOptions);
|
screen->showOverlayBanner(bannerOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
void menuHandler::themeMenu()
|
|
||||||
{
|
|
||||||
// Build menu dynamically from the theme table.
|
|
||||||
// Only visible themes appear!
|
|
||||||
// Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes.
|
|
||||||
// Bump kMaxThemesInMenu if you add more themes than will fit here.
|
|
||||||
constexpr size_t kMaxThemesInMenu = 15;
|
|
||||||
const size_t visibleCount = getVisibleThemeCount();
|
|
||||||
static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"};
|
|
||||||
const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu;
|
|
||||||
const int options = static_cast<int>(shownCount) + 1; // +1 for Back
|
|
||||||
|
|
||||||
for (size_t i = 0; i < shownCount; i++) {
|
|
||||||
optionsArray[i + 1] = getVisibleThemeByIndex(i).name;
|
|
||||||
}
|
|
||||||
|
|
||||||
BannerOverlayOptions bannerOptions;
|
|
||||||
bannerOptions.message = "Theme";
|
|
||||||
bannerOptions.optionsArrayPtr = optionsArray;
|
|
||||||
bannerOptions.optionsCount = options;
|
|
||||||
|
|
||||||
// Highlight the currently active theme (visible index + 1 for the Back
|
|
||||||
// offset). If the active theme is hidden, leave selection on "Back".
|
|
||||||
const size_t activeVisible = getActiveVisibleThemeIndex();
|
|
||||||
bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast<int>(activeVisible) + 1;
|
|
||||||
|
|
||||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
||||||
if (selected == 0) {
|
|
||||||
// Back
|
|
||||||
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
|
|
||||||
screen->runNow();
|
|
||||||
} else {
|
|
||||||
// Selection is an index into the VISIBLE themes (1-based, slot 0 is Back).
|
|
||||||
const size_t visibleIdx = static_cast<size_t>(selected - 1);
|
|
||||||
if (visibleIdx < getVisibleThemeCount()) {
|
|
||||||
// Persist the theme's uniqueIdentifier so boot-time
|
|
||||||
// resolveThemeIndex() can restore this theme on next startup.
|
|
||||||
uiconfig.screen_rgb_color = COLOR565(255, 255, (getVisibleThemeByIndex(visibleIdx).uniqueIdentifier & 0x1F) << 3);
|
|
||||||
loadThemeDefaults();
|
|
||||||
saveUIConfig();
|
|
||||||
screen->runNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
screen->showOverlayBanner(bannerOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||||
{
|
{
|
||||||
if (menuQueue != MenuNone)
|
if (menuQueue != MenuNone)
|
||||||
@@ -2665,6 +2723,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
|||||||
case MuiPicker:
|
case MuiPicker:
|
||||||
switchToMUIMenu();
|
switchToMUIMenu();
|
||||||
break;
|
break;
|
||||||
|
case TftColorMenuPicker:
|
||||||
|
TFTColorPickerMenu(display);
|
||||||
|
break;
|
||||||
case BrightnessPicker:
|
case BrightnessPicker:
|
||||||
BrightnessPickerMenu();
|
BrightnessPickerMenu();
|
||||||
break;
|
break;
|
||||||
@@ -2737,9 +2798,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
|||||||
case MessageBubblesMenu:
|
case MessageBubblesMenu:
|
||||||
messageBubblesMenu();
|
messageBubblesMenu();
|
||||||
break;
|
break;
|
||||||
case ThemeMenu:
|
|
||||||
themeMenu();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
menuQueue = MenuNone;
|
menuQueue = MenuNone;
|
||||||
}
|
}
|
||||||
@@ -2751,4 +2809,4 @@ void menuHandler::saveUIConfig()
|
|||||||
|
|
||||||
} // namespace graphics
|
} // namespace graphics
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -30,6 +30,7 @@ class menuHandler
|
|||||||
ResetNodeDbMenu,
|
ResetNodeDbMenu,
|
||||||
BuzzerModeMenuPicker,
|
BuzzerModeMenuPicker,
|
||||||
MuiPicker,
|
MuiPicker,
|
||||||
|
TftColorMenuPicker,
|
||||||
BrightnessPicker,
|
BrightnessPicker,
|
||||||
RebootMenu,
|
RebootMenu,
|
||||||
ShutdownMenu,
|
ShutdownMenu,
|
||||||
@@ -54,8 +55,7 @@ class menuHandler
|
|||||||
NodeNameLengthMenu,
|
NodeNameLengthMenu,
|
||||||
FrameToggles,
|
FrameToggles,
|
||||||
DisplayUnits,
|
DisplayUnits,
|
||||||
MessageBubblesMenu,
|
MessageBubblesMenu
|
||||||
ThemeMenu
|
|
||||||
};
|
};
|
||||||
static screenMenus menuQueue;
|
static screenMenus menuQueue;
|
||||||
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
|
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
|
||||||
@@ -89,6 +89,7 @@ class menuHandler
|
|||||||
static void GPSPositionBroadcastMenu();
|
static void GPSPositionBroadcastMenu();
|
||||||
static void BuzzerModeMenu();
|
static void BuzzerModeMenu();
|
||||||
static void switchToMUIMenu();
|
static void switchToMUIMenu();
|
||||||
|
static void TFTColorPickerMenu(OLEDDisplay *display);
|
||||||
static void nodeListMenu();
|
static void nodeListMenu();
|
||||||
static void resetNodeDBMenu();
|
static void resetNodeDBMenu();
|
||||||
static void BrightnessPickerMenu();
|
static void BrightnessPickerMenu();
|
||||||
@@ -109,7 +110,6 @@ class menuHandler
|
|||||||
static void frameTogglesMenu();
|
static void frameTogglesMenu();
|
||||||
static void displayUnitsMenu();
|
static void displayUnitsMenu();
|
||||||
static void messageBubblesMenu();
|
static void messageBubblesMenu();
|
||||||
static void themeMenu();
|
|
||||||
static void textMessageMenu();
|
static void textMessageMenu();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -136,10 +136,23 @@ template <typename T> struct MenuOption {
|
|||||||
MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {}
|
MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct ScreenColor {
|
||||||
|
uint8_t r;
|
||||||
|
uint8_t g;
|
||||||
|
uint8_t b;
|
||||||
|
bool useVariant;
|
||||||
|
|
||||||
|
explicit ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false)
|
||||||
|
: r(rIn), g(gIn), b(bIn), useVariant(variantIn)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
using RadioPresetOption = MenuOption<meshtastic_Config_LoRaConfig_ModemPreset>;
|
using RadioPresetOption = MenuOption<meshtastic_Config_LoRaConfig_ModemPreset>;
|
||||||
using LoraRegionOption = MenuOption<meshtastic_Config_LoRaConfig_RegionCode>;
|
using LoraRegionOption = MenuOption<meshtastic_Config_LoRaConfig_RegionCode>;
|
||||||
using TimezoneOption = MenuOption<const char *>;
|
using TimezoneOption = MenuOption<const char *>;
|
||||||
using CompassOption = MenuOption<meshtastic_CompassMode>;
|
using CompassOption = MenuOption<meshtastic_CompassMode>;
|
||||||
|
using ScreenColorOption = MenuOption<ScreenColor>;
|
||||||
using GPSToggleOption = MenuOption<meshtastic_Config_PositionConfig_GpsMode>;
|
using GPSToggleOption = MenuOption<meshtastic_Config_PositionConfig_GpsMode>;
|
||||||
using GPSFormatOption = MenuOption<meshtastic_DeviceUIConfig_GpsCoordinateFormat>;
|
using GPSFormatOption = MenuOption<meshtastic_DeviceUIConfig_GpsCoordinateFormat>;
|
||||||
using NodeNameOption = MenuOption<bool>;
|
using NodeNameOption = MenuOption<bool>;
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
#include "graphics/Screen.h"
|
#include "graphics/Screen.h"
|
||||||
#include "graphics/ScreenFonts.h"
|
#include "graphics/ScreenFonts.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/TFTColorRegions.h"
|
|
||||||
#include "graphics/TFTPalette.h"
|
|
||||||
#include "graphics/TimeFormatters.h"
|
#include "graphics/TimeFormatters.h"
|
||||||
#include "graphics/emotes.h"
|
#include "graphics/emotes.h"
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
@@ -256,76 +254,6 @@ struct MessageBlock {
|
|||||||
bool mine;
|
bool mine;
|
||||||
};
|
};
|
||||||
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine)
|
|
||||||
{
|
|
||||||
uint16_t bubbleOnColor;
|
|
||||||
uint16_t bubbleOffColor;
|
|
||||||
|
|
||||||
if (themeId == ThemeID::Blue) {
|
|
||||||
bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White;
|
|
||||||
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue;
|
|
||||||
} else {
|
|
||||||
bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg();
|
|
||||||
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius)
|
|
||||||
{
|
|
||||||
if (w <= 0 || h <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (radius <= 0 || w < 3 || h < 3) {
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while
|
|
||||||
// scrolling long message lists (which can flatten older bubble corners).
|
|
||||||
int capRows = 0;
|
|
||||||
if (radius >= 4 && h >= 5) {
|
|
||||||
capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps)
|
|
||||||
} else if (radius >= 2 && h >= 3) {
|
|
||||||
capRows = 1; // 3 regions total
|
|
||||||
}
|
|
||||||
if (capRows <= 0) {
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int row = 0; row < capRows; ++row) {
|
|
||||||
int inset = 0;
|
|
||||||
if (radius >= 4) {
|
|
||||||
inset = (row == 0) ? 2 : 1;
|
|
||||||
} else if (radius >= 2) {
|
|
||||||
inset = 1;
|
|
||||||
}
|
|
||||||
const int stripW = w - (inset * 2);
|
|
||||||
if (stripW <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int topY = y + row;
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1);
|
|
||||||
|
|
||||||
const int bottomY = y + h - 1 - row;
|
|
||||||
if (bottomY != topY) {
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const int middleY = y + capRows;
|
|
||||||
const int middleH = h - (capRows * 2);
|
|
||||||
if (middleH > 0) {
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
|
static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
|
||||||
{
|
{
|
||||||
if (isHeaderLine) {
|
if (isHeaderLine) {
|
||||||
@@ -720,11 +648,6 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
const int contentBottom = scrollBottom; // already excludes nav line
|
const int contentBottom = scrollBottom; // already excludes nav line
|
||||||
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
|
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
|
||||||
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
|
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
const uint32_t themeId = getActiveTheme().id;
|
|
||||||
// Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark.
|
|
||||||
const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
std::vector<int> lineTop;
|
std::vector<int> lineTop;
|
||||||
lineTop.resize(cachedLines.size());
|
lineTop.resize(cachedLines.size());
|
||||||
@@ -763,17 +686,6 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
|
int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
|
||||||
int bottomY = visualBottom + BUBBLE_PAD_Y;
|
int bottomY = visualBottom + BUBBLE_PAD_Y;
|
||||||
|
|
||||||
// On high-res screens, keep a 1px gap under the header
|
|
||||||
if (currentResolution == ScreenResolution::High) {
|
|
||||||
const int minTopY = contentTop + 1;
|
|
||||||
if (topY < minTopY) {
|
|
||||||
// Preserve bubble height when we push it down from the header.
|
|
||||||
const int shift = minTopY - topY;
|
|
||||||
topY = minTopY;
|
|
||||||
bottomY += shift;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bi + 1 < blocks.size()) {
|
if (bi + 1 < blocks.size()) {
|
||||||
int nextHeaderIndex = (int)blocks[bi + 1].start;
|
int nextHeaderIndex = (int)blocks[bi + 1].start;
|
||||||
int nextTop = lineTop[nextHeaderIndex];
|
int nextTop = lineTop[nextHeaderIndex];
|
||||||
@@ -823,56 +735,24 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
const int by = topY;
|
const int by = topY;
|
||||||
const int bw = bubbleW;
|
const int bw = bubbleW;
|
||||||
const int bh = bubbleH;
|
const int bh = bubbleH;
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
const bool drawBubbleOutline = !useDarkModeBubbleFill;
|
|
||||||
#else
|
|
||||||
const bool drawBubbleOutline = true;
|
|
||||||
#endif
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (useDarkModeBubbleFill) {
|
|
||||||
setDarkModeBubbleRoleColors(themeId, b.mine);
|
|
||||||
registerRoundedBubbleFillRegion(bx, by, bw, bh, r);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (drawBubbleOutline) {
|
// Draw the 4 corner arcs using drawCircleQuads
|
||||||
// Draw the 4 corner arcs using drawCircleQuads
|
display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
|
||||||
display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
|
display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
|
||||||
display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
|
display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
|
||||||
display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
|
display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
|
||||||
display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
|
|
||||||
|
|
||||||
// Draw the 4 edges between corners
|
// Draw the 4 edges between corners
|
||||||
display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
|
display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
|
||||||
display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
|
display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
|
||||||
display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
|
display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
|
||||||
display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
|
display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
|
||||||
}
|
|
||||||
} else if (bubbleW > 1 && bubbleH > 1) {
|
} else if (bubbleW > 1 && bubbleH > 1) {
|
||||||
// Fallback to simple rectangle for very small bubbles
|
// Fallback to simple rectangle for very small bubbles
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
|
||||||
const bool drawBubbleOutline = !useDarkModeBubbleFill;
|
|
||||||
#else
|
|
||||||
const bool drawBubbleOutline = true;
|
|
||||||
#endif
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (useDarkModeBubbleFill) {
|
|
||||||
setDarkModeBubbleRoleColors(themeId, b.mine);
|
|
||||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
if (drawBubbleOutline) {
|
|
||||||
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // end if (showBubbles)
|
} // end if (showBubbles)
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (useDarkModeBubbleFill) {
|
|
||||||
// Restore theme role defaults so other screens keep their intended palette.
|
|
||||||
loadThemeDefaults();
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Render visible lines
|
// Render visible lines
|
||||||
int lineY = yOffset;
|
int lineY = yOffset;
|
||||||
@@ -892,7 +772,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
|||||||
headerX = x + textIndent;
|
headerX = x + textIndent;
|
||||||
}
|
}
|
||||||
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
|
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
|
||||||
true);
|
false);
|
||||||
|
|
||||||
// Draw underline just under header text
|
// Draw underline just under header text
|
||||||
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
||||||
|
|||||||
@@ -3,16 +3,11 @@
|
|||||||
#include "CompassRenderer.h"
|
#include "CompassRenderer.h"
|
||||||
#include "NodeDB.h"
|
#include "NodeDB.h"
|
||||||
#include "NodeListRenderer.h"
|
#include "NodeListRenderer.h"
|
||||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
|
||||||
#include "modules/StatusMessageModule.h"
|
|
||||||
#endif
|
|
||||||
#include "UIRenderer.h"
|
#include "UIRenderer.h"
|
||||||
#include "gps/GeoCoord.h"
|
#include "gps/GeoCoord.h"
|
||||||
#include "gps/RTC.h" // for getTime() function
|
#include "gps/RTC.h" // for getTime() function
|
||||||
#include "graphics/ScreenFonts.h"
|
#include "graphics/ScreenFonts.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/TFTColorRegions.h"
|
|
||||||
#include "graphics/TFTPalette.h"
|
|
||||||
#include "graphics/images.h"
|
#include "graphics/images.h"
|
||||||
#include "meshUtils.h"
|
#include "meshUtils.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -26,7 +21,7 @@ extern bool haveGlyphs(const char *str);
|
|||||||
// Global screen instance
|
// Global screen instance
|
||||||
extern graphics::Screen *screen;
|
extern graphics::Screen *screen;
|
||||||
|
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
static uint32_t lastSwitchTime = 0;
|
static uint32_t lastSwitchTime = 0;
|
||||||
#endif
|
#endif
|
||||||
namespace graphics
|
namespace graphics
|
||||||
@@ -97,41 +92,8 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
|||||||
|
|
||||||
// 1) Choose target candidate (long vs short) only if present
|
// 1) Choose target candidate (long vs short) only if present
|
||||||
const char *raw = nullptr;
|
const char *raw = nullptr;
|
||||||
|
if (node && node->has_user) {
|
||||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
||||||
// If long-name mode is enabled, and we have a recent status for this node,
|
|
||||||
// prefer "(short_name) statusText" as the raw candidate.
|
|
||||||
std::string composedFromStatus;
|
|
||||||
if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) {
|
|
||||||
const auto &recent = statusMessageModule->getRecentReceived();
|
|
||||||
const StatusMessageModule::RecentStatus *found = nullptr;
|
|
||||||
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
|
|
||||||
if (it->fromNodeId == node->num && !it->statusText.empty()) {
|
|
||||||
found = &(*it);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
const char *shortName = node->user.short_name;
|
|
||||||
composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size());
|
|
||||||
composedFromStatus += "(";
|
|
||||||
if (shortName && *shortName) {
|
|
||||||
composedFromStatus += shortName;
|
|
||||||
}
|
|
||||||
composedFromStatus += ") ";
|
|
||||||
composedFromStatus += found->statusText;
|
|
||||||
|
|
||||||
raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// If we didn't compose from status, use normal long/short selection
|
|
||||||
if (!raw) {
|
|
||||||
if (node && node->has_user) {
|
|
||||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
|
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
|
||||||
@@ -215,33 +177,6 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName,
|
|
||||||
int16_t nameX, int16_t y, int nameMaxWidth)
|
|
||||||
{
|
|
||||||
if (!display || !node || !node->is_favorite || !isTFTColoringEnabled() || !nodeName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
|
|
||||||
const int regionWidth = min(textWidth, max(0, nameMaxWidth));
|
|
||||||
if (regionWidth <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node list rows can begin a couple of pixels inside header space.
|
|
||||||
// Clamp favorite-name color region below the header to avoid black overlap there.
|
|
||||||
const int16_t minContentY = static_cast<int16_t>(FONT_HEIGHT_SMALL + 1);
|
|
||||||
const int16_t regionY = max(y, minContentY);
|
|
||||||
const int16_t yClip = regionY - y;
|
|
||||||
const int16_t regionHeight = static_cast<int16_t>(FONT_HEIGHT_SMALL - yClip);
|
|
||||||
if (regionHeight <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::FavoriteNode, TFTPalette::Yellow, TFTPalette::Black, nameX, regionY, regionWidth,
|
|
||||||
regionHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================
|
// =============================
|
||||||
// Entry Renderers
|
// Entry Renderers
|
||||||
// =============================
|
// =============================
|
||||||
@@ -256,9 +191,6 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
|||||||
char nodeName[96];
|
char nodeName[96];
|
||||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||||
nameMaxWidth);
|
nameMaxWidth);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
|
||||||
#endif
|
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||||
|
|
||||||
char timeStr[10];
|
char timeStr[10];
|
||||||
@@ -307,20 +239,14 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
|||||||
|
|
||||||
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
|
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
|
||||||
int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
|
int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
|
||||||
constexpr int kBarCount = 4;
|
int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
|
||||||
constexpr int kBarWidth = 2;
|
|
||||||
constexpr int kBarGap = 1;
|
|
||||||
|
|
||||||
int barsXOffset = columnWidth - barsOffset;
|
int barsXOffset = columnWidth - barsOffset;
|
||||||
int barsRightEdge = x + barsXOffset + ((kBarCount - 1) * (kBarWidth + kBarGap)) + kBarWidth;
|
|
||||||
|
|
||||||
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
|
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
|
||||||
char nodeName[96];
|
char nodeName[96];
|
||||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||||
nameMaxWidth);
|
nameMaxWidth);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
|
||||||
#endif
|
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||||
|
|
||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
@@ -342,48 +268,28 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool isZeroHop = node->has_hops_away && node->hops_away == 0;
|
// Draw signal strength bars
|
||||||
|
int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
|
||||||
|
int barWidth = 2;
|
||||||
|
int barStartX = x + barsXOffset;
|
||||||
|
int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2;
|
||||||
|
|
||||||
// Show signal only for direct neighbors (0 hops)
|
for (int b = 0; b < 4; b++) {
|
||||||
if (isZeroHop) {
|
if (b < bars) {
|
||||||
int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
|
int height = (b * 2);
|
||||||
int barStartX = x + barsXOffset;
|
display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height);
|
||||||
int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2;
|
|
||||||
|
|
||||||
if (bars > 0) {
|
|
||||||
uint16_t signalBarsColor = TFTPalette::Bad;
|
|
||||||
if (bars >= 3) {
|
|
||||||
signalBarsColor = TFTPalette::Good;
|
|
||||||
} else if (bars == 2) {
|
|
||||||
signalBarsColor = TFTPalette::Medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highest bar reaches 6 px in this renderer.
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barStartX, barStartY - 6,
|
|
||||||
(kBarCount * kBarWidth) + ((kBarCount - 1) * kBarGap), 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int b = 0; b < kBarCount; b++) {
|
|
||||||
if (b < bars) {
|
|
||||||
int height = (b * 2);
|
|
||||||
display->fillRect(barStartX + (b * (kBarWidth + kBarGap)), barStartY - height, kBarWidth, height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw hop count + hop icon
|
// Draw hop count
|
||||||
if (node->has_hops_away && node->hops_away > 0) {
|
char hopStr[6] = "";
|
||||||
char hopCount[6];
|
if (node->has_hops_away && node->hops_away > 0)
|
||||||
snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away);
|
snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away);
|
||||||
|
|
||||||
const int hopCountWidth = display->getStringWidth(hopCount);
|
if (hopStr[0] != '\0') {
|
||||||
const int gap = 1;
|
int rightEdge = x + columnWidth - hopOffset;
|
||||||
const int totalWidth = hopCountWidth + gap + hop_width;
|
int textWidth = display->getStringWidth(hopStr);
|
||||||
const int hopX = barsRightEdge - totalWidth;
|
display->drawString(rightEdge - textWidth, y, hopStr);
|
||||||
const int iconY = y + (FONT_HEIGHT_SMALL - hop_height) / 2;
|
|
||||||
|
|
||||||
display->drawString(hopX, y, hopCount);
|
|
||||||
display->drawXbm(hopX + hopCountWidth + gap, iconY, hop_width, hop_height, hop);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,9 +304,6 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
char nodeName[96];
|
char nodeName[96];
|
||||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||||
nameMaxWidth);
|
nameMaxWidth);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
|
||||||
#endif
|
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||||
char distStr[10] = "";
|
char distStr[10] = "";
|
||||||
|
|
||||||
@@ -470,13 +373,14 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *distanceLabel = (strlen(distStr) > 0) ? distStr : "?";
|
if (strlen(distStr) > 0) {
|
||||||
int offset = (currentResolution == ScreenResolution::High)
|
int offset = (currentResolution == ScreenResolution::High)
|
||||||
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
||||||
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
||||||
int rightEdge = x + columnWidth - offset;
|
int rightEdge = x + columnWidth - offset;
|
||||||
int textWidth = display->getStringWidth(distanceLabel);
|
int textWidth = display->getStringWidth(distStr);
|
||||||
display->drawString(rightEdge - textWidth, y, distanceLabel);
|
display->drawString(rightEdge - textWidth, y, distStr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
||||||
@@ -506,9 +410,6 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
char nodeName[96];
|
char nodeName[96];
|
||||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||||
nameMaxWidth);
|
nameMaxWidth);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
|
||||||
#endif
|
|
||||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||||
|
|
||||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
@@ -530,8 +431,8 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||||
float myHeadingRadian, double userLat, double userLon)
|
double userLat, double userLon)
|
||||||
{
|
{
|
||||||
if (!nodeDB->hasValidPosition(node))
|
if (!nodeDB->hasValidPosition(node))
|
||||||
return;
|
return;
|
||||||
@@ -545,11 +446,11 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
double nodeLat = node->position.latitude_i * 1e-7;
|
double nodeLat = node->position.latitude_i * 1e-7;
|
||||||
double nodeLon = node->position.longitude_i * 1e-7;
|
double nodeLon = node->position.longitude_i * 1e-7;
|
||||||
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
|
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
|
||||||
float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian);
|
float bearingToNode = RAD_TO_DEG * bearing;
|
||||||
float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing);
|
float relativeBearing = fmod((bearingToNode - myHeading + 360), 360);
|
||||||
// Shrink size by 2px
|
// Shrink size by 2px
|
||||||
int size = FONT_HEIGHT_SMALL - 5;
|
int size = FONT_HEIGHT_SMALL - 5;
|
||||||
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg);
|
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearing);
|
||||||
/*
|
/*
|
||||||
float angle = relativeBearing * DEG_TO_RAD;
|
float angle = relativeBearing * DEG_TO_RAD;
|
||||||
float halfSize = size / 2.0;
|
float halfSize = size / 2.0;
|
||||||
@@ -579,27 +480,12 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
|||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float, double,
|
|
||||||
double)
|
|
||||||
{
|
|
||||||
if (!nodeDB->hasValidPosition(node))
|
|
||||||
return;
|
|
||||||
|
|
||||||
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
|
||||||
int arrowXOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18);
|
|
||||||
int centerX = x + columnWidth - arrowXOffset;
|
|
||||||
|
|
||||||
display->setFont(FONT_SMALL);
|
|
||||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
|
||||||
display->drawString(centerX, y, "?");
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================
|
// =============================
|
||||||
// Main Screen Functions
|
// Main Screen Functions
|
||||||
// =============================
|
// =============================
|
||||||
|
|
||||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||||
EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon)
|
EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon)
|
||||||
{
|
{
|
||||||
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
|
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
|
||||||
const int rowYOffset = FONT_HEIGHT_SMALL - 3;
|
const int rowYOffset = FONT_HEIGHT_SMALL - 3;
|
||||||
@@ -684,7 +570,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
|||||||
renderer(display, node, xPos, yPos, columnWidth);
|
renderer(display, node, xPos, yPos, columnWidth);
|
||||||
|
|
||||||
if (extras)
|
if (extras)
|
||||||
extras(display, node, xPos, yPos, columnWidth, headingRadian, lat, lon);
|
extras(display, node, xPos, yPos, columnWidth, heading, lat, lon);
|
||||||
|
|
||||||
lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
|
lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
|
||||||
yOffset += rowYOffset;
|
yOffset += rowYOffset;
|
||||||
@@ -764,9 +650,6 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
|||||||
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
|
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
|
||||||
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
|
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
display->drawString(boxLeft + padding, boxTop + padding, buf);
|
display->drawString(boxLeft + padding, boxTop + padding, buf);
|
||||||
@@ -787,7 +670,7 @@ void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state
|
|||||||
|
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
display->clear();
|
display->clear();
|
||||||
if (now - lastSwitchTime >= 3000) {
|
if (now - lastSwitchTime >= 3000) {
|
||||||
display->display();
|
display->display();
|
||||||
@@ -823,7 +706,7 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st
|
|||||||
|
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
display->clear();
|
display->clear();
|
||||||
if (now - lastSwitchTime >= 3000) {
|
if (now - lastSwitchTime >= 3000) {
|
||||||
display->display();
|
display->display();
|
||||||
@@ -882,17 +765,13 @@ void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
|||||||
#endif
|
#endif
|
||||||
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||||
{
|
{
|
||||||
float headingRadian = 0.0f;
|
float heading = 0;
|
||||||
|
bool validHeading = false;
|
||||||
auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||||
if (!ourNode || !nodeDB->hasValidPosition(ourNode)) {
|
|
||||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double lat = DegD(ourNode->position.latitude_i);
|
double lat = DegD(ourNode->position.latitude_i);
|
||||||
double lon = DegD(ourNode->position.longitude_i);
|
double lon = DegD(ourNode->position.longitude_i);
|
||||||
|
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
display->clear();
|
display->clear();
|
||||||
uint32_t now = millis();
|
uint32_t now = millis();
|
||||||
if (now - lastSwitchTime >= 2000) {
|
if (now - lastSwitchTime >= 2000) {
|
||||||
@@ -900,12 +779,21 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
|
|||||||
lastSwitchTime = now;
|
lastSwitchTime = now;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
if (!CompassRenderer::getHeadingRadians(lat, lon, headingRadian)) {
|
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) {
|
||||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, lat, lon);
|
#if HAS_GPS
|
||||||
return;
|
if (screen->hasHeading()) {
|
||||||
}
|
heading = screen->getHeading(); // degrees
|
||||||
|
validHeading = true;
|
||||||
|
} else {
|
||||||
|
heading = screen->estimatedHeading(lat, lon);
|
||||||
|
validHeading = !isnan(heading);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon);
|
if (!validHeading)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a series of fields in a column, wrapping to multiple columns if needed
|
/// Draw a series of fields in a column, wrapping to multiple columns if needed
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATIO
|
|||||||
|
|
||||||
// Main node list screen function
|
// Main node list screen function
|
||||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||||
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float headingRadian = 0, double lat = 0,
|
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0,
|
||||||
double lon = 0);
|
double lon = 0);
|
||||||
|
|
||||||
// Entry renderers
|
// Entry renderers
|
||||||
@@ -43,8 +43,8 @@ void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
|||||||
void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
|
void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
|
||||||
|
|
||||||
// Extras renderers
|
// Extras renderers
|
||||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||||
float myHeadingRadian, double userLat, double userLon);
|
double userLat, double userLon);
|
||||||
|
|
||||||
// Screen frame functions
|
// Screen frame functions
|
||||||
void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
#include "UIRenderer.h"
|
#include "UIRenderer.h"
|
||||||
#include "graphics/ScreenFonts.h"
|
#include "graphics/ScreenFonts.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/TFTColorRegions.h"
|
|
||||||
#include "graphics/TFTPalette.h"
|
|
||||||
#include "graphics/images.h"
|
#include "graphics/images.h"
|
||||||
#include "input/RotaryEncoderInterruptImpl1.h"
|
#include "input/RotaryEncoderInterruptImpl1.h"
|
||||||
#include "input/UpDownInterruptImpl1.h"
|
#include "input/UpDownInterruptImpl1.h"
|
||||||
@@ -582,7 +580,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
|||||||
}
|
}
|
||||||
int16_t boxTop = (display->height() / 2) - (boxHeight / 2);
|
int16_t boxTop = (display->height() / 2) - (boxHeight / 2);
|
||||||
boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1;
|
boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1;
|
||||||
#if defined(M5STACK_UNITC6L)
|
#if defined(OLED_TINY)
|
||||||
if (visibleTotalLines == 1) {
|
if (visibleTotalLines == 1) {
|
||||||
boxTop += 25;
|
boxTop += 25;
|
||||||
}
|
}
|
||||||
@@ -610,9 +608,6 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
|||||||
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
|
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
|
||||||
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
|
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
|
||||||
display->setColor(WHITE);
|
display->setColor(WHITE);
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Draw Content
|
// Draw Content
|
||||||
int16_t lineY = boxTop + vPadding;
|
int16_t lineY = boxTop + vPadding;
|
||||||
@@ -635,21 +630,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
|||||||
if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) {
|
if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) {
|
||||||
background_yOffset = -1;
|
background_yOffset = -1;
|
||||||
}
|
}
|
||||||
const int16_t titleBarY = boxTop + 1;
|
display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
|
||||||
const int16_t titleBarHeight = effectiveLineHeight - background_yOffset;
|
|
||||||
display->fillRect(boxLeft, titleBarY, boxWidth, titleBarHeight);
|
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (alertBannerOptions > 0) {
|
|
||||||
const uint16_t titleTextColor =
|
|
||||||
(getActiveTheme().id == ThemeID::DefaultLight) ? TFTPalette::Black : getThemeHeaderText();
|
|
||||||
// Keep title role away from border/corner pixels so rounded-corner masks are not remapped to the title text
|
|
||||||
// color.
|
|
||||||
if (boxWidth > 2 && titleBarHeight > 0) {
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuTitle, getThemeHeaderBg(), titleTextColor, boxLeft + 1,
|
|
||||||
titleBarY, boxWidth - 2, titleBarHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
display->setColor(BLACK);
|
display->setColor(BLACK);
|
||||||
int yOffset = 3;
|
int yOffset = 3;
|
||||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||||
@@ -669,7 +650,6 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
|||||||
const int barSpacing = 2;
|
const int barSpacing = 2;
|
||||||
const int barHeightStep = 2;
|
const int barHeightStep = 2;
|
||||||
const int gap = 6;
|
const int gap = 6;
|
||||||
const int maxBarHeight = totalBars * barHeightStep;
|
|
||||||
|
|
||||||
int textWidth = display->getStringWidth(lineBuffer, strlen(lineBuffer), true);
|
int textWidth = display->getStringWidth(lineBuffer, strlen(lineBuffer), true);
|
||||||
int barsWidth = totalBars * barWidth + (totalBars - 1) * barSpacing + gap;
|
int barsWidth = totalBars * barWidth + (totalBars - 1) * barSpacing + gap;
|
||||||
@@ -684,20 +664,6 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
|||||||
|
|
||||||
int baseX = groupStartX + textWidth + gap;
|
int baseX = groupStartX + textWidth + gap;
|
||||||
int baseY = lineY + effectiveLineHeight - 1;
|
int baseY = lineY + effectiveLineHeight - 1;
|
||||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
||||||
if (graphics::bannerSignalBars > 0) {
|
|
||||||
uint16_t signalBarsColor = TFTPalette::Medium;
|
|
||||||
if (graphics::bannerSignalBars <= 1) {
|
|
||||||
signalBarsColor = TFTPalette::Bad;
|
|
||||||
} else if (graphics::bannerSignalBars >= 4) {
|
|
||||||
signalBarsColor = TFTPalette::Good;
|
|
||||||
}
|
|
||||||
const int activeBars = min(graphics::bannerSignalBars, totalBars);
|
|
||||||
const int regionWidth = activeBars * barWidth + (activeBars - 1) * barSpacing;
|
|
||||||
setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, baseX,
|
|
||||||
baseY - maxBarHeight, regionWidth, maxBarHeight);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
for (int b = 0; b < totalBars; b++) {
|
for (int b = 0; b < totalBars; b++) {
|
||||||
int barHeight = (b + 1) * barHeightStep;
|
int barHeight = (b + 1) * barHeightStep;
|
||||||
int x = baseX + b * (barWidth + barSpacing);
|
int x = baseX + b * (barWidth + barSpacing);
|
||||||
|
|||||||
+342
-781
File diff suppressed because it is too large
Load Diff
@@ -50,12 +50,10 @@ class UIRenderer
|
|||||||
// Navigation bar overlay
|
// Navigation bar overlay
|
||||||
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
|
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||||
|
|
||||||
static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||||
|
|
||||||
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||||
|
|
||||||
static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
|
||||||
|
|
||||||
// Icon and screen drawing functions
|
// Icon and screen drawing functions
|
||||||
static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||||
|
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ const uint8_t chirpy_small[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f};
|
|||||||
#define connection_icon_height 5
|
#define connection_icon_height 5
|
||||||
const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36};
|
const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36};
|
||||||
|
|
||||||
#ifdef M5STACK_UNITC6L
|
#ifdef OLED_TINY
|
||||||
#include "img/icon_small.xbm"
|
#include "img/icon_small.xbm"
|
||||||
#else
|
#else
|
||||||
#include "img/icon.xbm"
|
#include "img/icon.xbm"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user