Compare commits

..
418 changed files with 5060 additions and 19790 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "f=$(tr -d '\\n' | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed 's/.*:[[:space:]]*\"//; s/\"$//'); [ -n \"$f\" ] && [ -f \"$f\" ] || exit 0; t=$(command -v trunk || echo \"$HOME/.cache/trunk/launcher/trunk\"); [ -x \"$t\" ] || { echo \"trunk-fmt hook: trunk not found; its launcher needs curl or wget to bootstrap the CLI (see 'Formatting & the trunk toolchain' in .github/copilot-instructions.md)\" >&2; exit 1; }; out=$(\"$t\" fmt --force \"$f\" 2>&1) || { echo \"trunk-fmt hook: trunk fmt failed on $f: $out\" >&2; exit 1; }",
"timeout": 120,
"statusMessage": "Formatting (trunk)..."
}
]
}
]
}
}
+4 -30
View File
@@ -191,24 +191,7 @@ Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which d
### Eviction
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `demoteOldestHotNodesToWarm` (the over-cap warm-tier migration), `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. (Note: `enforceSatelliteCaps`/`evictSatelliteOverCap` call `.erase()` directly on purpose — that's a satellite-only cap trim where the node _stays_ in the header, a different operation from this chokepoint.)
### Warm tier (long-tail identity)
On every arch except STM32WL and bare nRF52832 (`WARM_NODE_COUNT > 0`), a node evicted from the header table is not forgotten outright: `WarmNodeStore` (`src/mesh/WarmNodeStore.{h,cpp}`) keeps a 40 B `{num, last_heard, public_key}` record per evicted node — primarily so PKI DMs to/from a long-tail node keep decrypting without re-running a NodeInfo exchange (the rest of `NodeInfoLite` rebuilds from traffic in seconds).
- **Write:** `getOrCreateMeshNode`'s eviction and `demoteOldestHotNodesToWarm` (the over-cap boot migration) call `warmStore.absorb(num, last_heard, key)` _before_ the node leaves the header.
- **Read-back:** `getOrCreateMeshNode` calls `warmStore.take()` to rehydrate `last_heard` + key when a warm node is re-admitted; `copyPublicKey()` falls back to the warm tier so the PKI send path finds keys for evicted peers.
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring at `0xEA000` (below LittleFS; append + replay + compact-on-rotate, link-guarded by `nrf52840_s140_v7.ld` and `extra_scripts/nrf52_warm_region.py`). Everywhere else: a `/prefs/warm.dat` snapshot flushed by `saveIfDirty()` on the node-DB save cadence.
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap — 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 keeps its flash-scaled 100/200/250, portduino 250). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
### Satellite caps
Only the freshest `MAX_SATELLITE_NODES` nodes keep satellite payloads; the rest of the header table carries just the `NodeInfoLite`. The cap is **per-platform**: 40 on RAM-constrained parts (nRF52840, generic ESP32) since the four maps live in internal SRAM (not PSRAM, ~408 B/node across the four), and 250 on flash-rich hosts (ESP32-S3, portduino) so every hot node can carry rich data as before the cap existed. `enforceSatelliteCaps()` trims each map to the cap on load (returns whether it trimmed); `evictSatelliteOverCap()` trims before each insert. Eviction is by the owning node's hot `last_heard` (stalest first, demoted/absent nodes rank as `last_heard==0`); self is never trimmed.
### On-boot self-care
`NodeDB::nodeDBSelfCare()` runs once identity is established (the constructor after key (re)gen, and `reloadFromDisk()`_not_ inside `loadFromDisk`, where `getNodeNum()` is still 0). It confirms self is present (warns if a non-empty DB is missing us — a foreign/over-cap file), pins self to index 0, demotes/trims only **non-self** overflow into the warm tier, then rewrites `nodes.proto` **once** and only if it healed something — and never while encrypted storage is locked (it would persist placeholder defaults). `loadFromDisk` deliberately leaves the loaded store untrimmed for this pass.
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly.
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
@@ -300,15 +283,6 @@ firmware/
## Coding Conventions
### Formatting & the trunk toolchain
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
### General Style
- Follow existing code style - run `trunk fmt` before commits
@@ -402,14 +376,14 @@ Multiple display driver families in `src/graphics/`:
- **OLED**: SSD1306, SH1106, ST7567
- **TFT**: TFTDisplay (LovyanGFX-based)
- **E-Ink**: EInkDisplay2, EInkDynamicDisplay, EInkParallelDisplay
- **E-Ink**: `src/graphics/BaseUIEInkDisplay.*` is the OLEDDisplay-compatible adapter (peer of `TFTDisplay`). The hardware layer it drives lives in `src/graphics/eink/` — chipset drivers in `Drivers/`, panel profiles in `Panels/`, optional `Backlight/`. Shared by both BaseUI-on-eink and InkHUD builds.
**InkHUD** (`src/graphics/niche/InkHUD/`) is an event-driven e-ink UI framework:
**InkHUD** (`src/graphics/niche/`) is an event-driven e-ink UI framework that sits on top of the `graphics/eink/` layer:
- Applet-based architecture — modular display tiles
- Read-only, static display optimized for minimal refreshes and low power
- Configured per-variant via `nicheGraphics.h`
- Separate PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
- Build helpers in top-level `platformio.ini``[niche]` pulls `graphics/eink/` only (BaseUI path), `[inkhud]` extends it with `graphics/niche/`
### Input System
+1 -1
View File
@@ -132,7 +132,7 @@ For e-ink display variants using the InkHUD framework, add `nicheGraphics.h`:
// Configure display, applets, and refresh behavior per device
```
InkHUD has its own PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
InkHUD and the shared E-Ink layer are wired up via the top-level `platformio.ini` `[niche]` (BaseUI + driver/panel layer in `src/graphics/eink/`) and `[inkhud]` (adds the InkHUD UI in `src/graphics/niche/`). Variants opt in with `extends = ..., niche` or `extends = ..., inkhud`.
## I2C Device Detection
-187
View File
@@ -1,187 +0,0 @@
name: Post Web Flasher Link Comment
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
pull-requests: write
actions: read
jobs:
post-flasher-link:
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'cancelled' &&
github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
# Per-board manifests carry the firmware's own metadata (activelySupported,
# displayName, ...) generated from each target's custom_meshtastic_* config.
- name: Download board manifests
uses: actions/download-artifact@v8
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: manifest-*
path: ./manifests
merge-multiple: true
- name: Post or update web flasher link comment
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const run = context.payload.workflow_run;
const { owner, repo } = context.repo;
// Resolve the PR by matching the run's head SHA against the repo's open
// PRs. workflow_run.pull_requests is empty for fork PRs, and
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
// its head commit — but pulls.list includes fork PRs. Matching on head
// SHA also enforces that the run is for the PR's current commit, so stale
// re-runs of an outdated commit won't match.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
const pr = openPrs.find((p) => p.head.sha === run.head_sha);
if (!pr) {
core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
return;
}
const prNumber = pr.number;
// Restrict to trusted authors. NOTE: author_association is computed for
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
// alone silently excludes most maintainers. We allow the trusted set the
// token can actually identify (members, collaborators, and anyone with a
// previously merged PR). For strict members-only you'd need an org-read
// App/PAT token to call orgs.checkMembershipForUser.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Require at least one per-arch firmware artifact from gather-artifacts
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner, repo, run_id: run.id, per_page: 100,
});
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
if (archArtifacts.length === 0) {
core.info('No per-arch firmware artifacts found; skipping.');
return;
}
const version = archRe.exec(archArtifacts[0].name)[2];
const expiresAt = archArtifacts[0].expires_at
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
: null;
// Read each built board's manifest (.mt.json). activelySupported,
// displayName and architecture come straight from the board's
// custom_meshtastic_* platformio config, so the list is in sync with
// the firmware itself — no external device database needed.
const fs = require('fs');
let boards = [];
try {
boards = fs.readdirSync('./manifests')
.filter((f) => f.endsWith('.mt.json'))
.map((f) => {
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
catch { return null; }
})
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
.map((m) => ({
board: m.platformioTarget,
platform: m.architecture || '',
// displayName is maintainer-authored text; escape table-breaking pipes
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
}))
.sort((a, b) => a.board.localeCompare(b.board));
} catch (e) {
core.warning(`Could not read board manifests: ${e.message}`);
}
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
// Device illustrations are served by the flasher from the same image
// names the manifest declares (custom_meshtastic_images). The flasher
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
// image really resolves to an image before linking it.
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
await Promise.all(boards.map(async (b) => {
if (!b.image) return;
try {
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
const type = res.headers.get('content-type') || '';
if (!res.ok || !type.startsWith('image/')) b.image = '';
} catch { b.image = ''; }
}));
const boardLines = boards
.map((b) => {
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
})
.join('\n');
// Shields.io badges. Only non-user-controlled, charset-constrained values
// (version, commit sha, counts, dates) go into badge URLs — never board
// names or the PR title — so the rendered comment cannot be spoofed.
const shieldText = (s) =>
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
const shield = (label, message, color) =>
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
const buttonUrl =
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
const badges = [
`![firmware](${shield('firmware', version, '67EA94')})`,
`![commit](${shield('commit', run.head_sha.slice(0, 7), '2C2D3C')})`,
`![boards](${shield('boards', boards.length, '5C6BC0')})`,
];
if (expiresAt) badges.push(`![expires](${shield('expires', expiresAt, '9A4E00')})`);
// Only render the board table when there are supported boards to list
const boardTable = boards.length > 0 ? [
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
'',
'| | Device | Board | Platform |',
'| --- | --- | --- | --- |',
boardLines,
'',
'</details>',
'',
] : [];
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
`[![Flash this PR in the Web Flasher](${buttonUrl})](${flasherUrl})`,
'',
badges.join(' '),
'',
'> [!WARNING]',
'> This is an automated, unreviewed CI test build. Back up your device configuration',
'> before flashing, and only flash devices you are able to recover.',
'',
...boardTable,
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
].join('\n');
// Sticky comment: update in place when the marker is found
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber, per_page: 100,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
@@ -1,62 +0,0 @@
name: Post Web Flasher Build Placeholder
# Drops an immediate "build in progress" comment when a PR opens, so the web
# flasher entry shows up right away. The real CI-driven workflow
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
#
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
# safe because it never checks out or runs PR code and posts a fully static body
# — no PR title, branch name, or other untrusted input is used anywhere.
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
post-placeholder:
if: github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Post web flasher build-in-progress placeholder
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
// Trusted authors only (matches the real workflow). author_association
// can't reflect private org membership for the token, so concealed
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Only seed a placeholder when no flasher comment exists yet — never
// overwrite a real (or existing placeholder) comment.
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
if (comments.some((c) => c.body?.includes(marker))) {
core.info('Flasher comment already exists; nothing to do.');
return;
}
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
'> [!NOTE]',
'> Building this pull request… the flash button, badges and supported-board',
'> list will appear here automatically once CI finishes.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
+6 -7
View File
@@ -82,9 +82,8 @@ jobs:
fail-fast: false
matrix:
check: ${{ fromJson(needs.setup.outputs.check) }}
# Runs on GitHub-hosted runners so checks don't compete with builds for the
# self-hosted 'arctastic' pool (which builds use).
runs-on: ubuntu-latest
# Use 'arctastic' self-hosted runner pool when checking in the main repo
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
steps:
- uses: actions/checkout@v6
@@ -287,11 +286,11 @@ jobs:
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
cp "./baseline-develop/current-sizes.json" ./develop-sizes.json
cp "./baseline-develop/${ARTIFACT_NAME}/current-sizes.json" ./develop-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
@@ -312,11 +311,11 @@ jobs:
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-master/
cp "./baseline-master/current-sizes.json" ./master-sizes.json
cp "./baseline-master/${ARTIFACT_NAME}/current-sizes.json" ./master-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
+1 -29
View File
@@ -37,33 +37,13 @@ jobs:
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
- name: Integration test
# Cap the whole step: if the simulator ever fails to exit (e.g. the
# exit_simulator admin path regresses again) the job must fail fast,
# not run to GitHub's 6-hour limit.
timeout-minutes: 5
run: |
.pio/build/coverage/meshtasticd -s &
PID=$!
trap 'kill "$PID" 2>/dev/null || true' EXIT
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
echo "Simulator started, launching python test..."
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
# The Python harness sends exit_simulator and exits; the simulator is
# expected to terminate on its own. Give it a moment, then verify.
# If it is still alive the exit handshake is broken — fail loudly and
# do NOT fall through to `wait`, which would otherwise block until the
# job's hard timeout.
for i in $(seq 1 10); do
kill -0 "$PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$PID" 2>/dev/null; then
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
kill -9 "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
exit 1
fi
wait "$PID" 2>/dev/null || true
wait
- name: Capture coverage information
if: always() # run this step even if previous step failed
@@ -161,14 +141,6 @@ jobs:
name: platformio-test-report-${{ steps.version.outputs.long }}
merge-multiple: true
- name: Drop no-status testsuites from the report
# PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_* dir
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
# them so the Test Report lists only suites with a real status. Only the copy the
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
run: sed -i -E 's#<testsuite [^>]*tests="0"[^>]*/>##g' testreport.xml
- name: Test Report
uses: dorny/test-reporter@v3.0.0
with:
+4 -9
View File
@@ -16,18 +16,13 @@ jobs:
submodules: true
- name: Update submodule
if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }}
working-directory: protobufs
env:
# Use the branch that triggered the workflow as the protobuf branch.
GIT_BRANCH: ${{ github.ref_name }}
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
run: |
git fetch --prune origin $GIT_BRANCH
git checkout origin/$GIT_BRANCH
git submodule update --remote protobufs
- name: Download nanopb
run: |
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz
mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9
@@ -38,7 +33,7 @@ jobs:
- name: Create pull request
uses: peter-evans/create-pull-request@v8
with:
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
branch: create-pull-request/update-protobufs
labels: submodules
title: Update protobufs and classes
commit-message: Update protobufs
+1 -1
View File
@@ -64,7 +64,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
- **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. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
-32
View File
@@ -10,35 +10,3 @@
## Reporting a Vulnerability
We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.
Before filing, please read the Security Model below. Behavior whose only precondition is local API access to a node, or possession of a channel's pre-shared key, is intended by design and is not considered a vulnerability.
## Security Model
Meshtastic is an off-grid mesh protocol that runs on constrained microcontrollers within a 256 byte LoRa packet limit. These constraints shape its security design and rule out the heavier schemes used by IP-based protocols. This section summarizes what the firmware protects, the assumptions it rests on, and its known limits. Fuller write-ups are in the documentation:
- Encryption overview: https://meshtastic.org/docs/overview/encryption/
- Technical reference: https://meshtastic.org/docs/development/reference/encryption-technical/
- Known limitations and future work: https://meshtastic.org/docs/about/overview/encryption/limitations/
### Cryptographic mechanisms
- Channels are encrypted with a pre-shared key (PSK) using AES256-CTR. Channel traffic is encrypted but not authenticated, so anyone holding the PSK can read channel messages and can send messages as any node on that channel.
- Direct messages and admin messages use public key cryptography (x25519 key exchange with AES-CCM), providing confidentiality, authentication, and integrity between nodes on 2.5.0 or newer that have exchanged keys.
- Admin sessions use short-lived session IDs to limit replay of control messages.
### Local trust boundary
A client connected to a node over Bluetooth, USB serial, WiFi, or Ethernet has full local API access. From that connection it can read decrypted traffic, send messages as the node, change configuration (subject to managed mode), and read the node's private key for backup. This is intended behavior. The firmware trusts the local link the same way a phone or laptop trusts a directly attached device, and anything within reach of that connection (a shared LAN, a USB cable to an untrusted host, a paired phone) should be treated as part of the node itself.
### Node identity (Trust On First Use)
There is no central authority to sign node keys. The first public key a node hears for a given node number is the one it binds to that node number, a Trust On First Use (TOFU) model that is a hard requirement of a decentralized mesh. Clients and firmware reduce the impact of this by keeping favorited nodes from rolling out of the node database and by flagging public-key changes in the client UI.
Firmware 2.8.X adds XEdDSA packet signing to further secure node identity claims and the authenticity of subsequent messages. It reuses each node's existing x25519 key pair to produce signatures, so a receiver can verify that a packet came from the holder of the bound key. Once a node has been seen signing, unsigned packets claiming that identity can be rejected.
### Known limitations
- No perfect forward secrecy. Traffic captured today can be decrypted later if a key is compromised, for example through a lost node or a mishandled channel key.
- Channel messages are not authenticated, as noted above. Although as of 2.8, channel messages will be xedDSA signed as a means of verification that is non-breaking.
- Setting WiFi credentials, or performing any other local administration, on an ESP32 over an untrusted network exposes that traffic, including the credentials, to the network. Provision and administer nodes over a trusted channel instead: Bluetooth, USB serial, or remote admin over the mesh. There is no current roadmap item to secure local administration over untrusted WiFi, though it may be addressed in a future release.
-252
View File
@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""
Meshtastic Ethernet OTA Upload Tool
Uploads firmware to RP2350-based Meshtastic devices via Ethernet (W5500).
Compresses firmware with GZIP and sends it over TCP using the MOTA protocol.
Authenticates using SHA256 challenge-response with a pre-shared key (PSK).
Usage:
python bin/eth-ota-upload.py --host 192.168.1.100 firmware.bin
python bin/eth-ota-upload.py --host 192.168.1.100 --psk mySecretKey firmware.bin
python bin/eth-ota-upload.py --host 192.168.1.100 --psk-hex 6d65736874... firmware.bin
"""
import argparse
import gzip
import hashlib
import socket
import struct
import sys
import time
# Default PSK matching the firmware default: "meshtastic_ota_default_psk_v1!!!"
DEFAULT_PSK = b"meshtastic_ota_default_psk_v1!!!"
def crc32(data: bytes) -> int:
"""Compute CRC32 matching ErriezCRC32 (standard CRC32 with final XOR)."""
import binascii
return binascii.crc32(data) & 0xFFFFFFFF
def load_firmware(path: str) -> bytes:
"""Load firmware file, compressing with GZIP if not already compressed."""
# Reject UF2 files — OTA requires raw .bin firmware
if path.lower().endswith(".uf2"):
bin_path = path.rsplit(".", 1)[0] + ".bin"
print(f"ERROR: UF2 files cannot be used for OTA updates.")
print(f" The Updater/picoOTA expects raw .bin firmware.")
print(f" Try: {bin_path}")
sys.exit(1)
with open(path, "rb") as f:
data = f.read()
# Check if already GZIP compressed (magic bytes 1f 8b)
if data[:2] == b"\x1f\x8b":
print(f"Firmware already GZIP compressed: {len(data):,} bytes")
return data
print(f"Firmware raw size: {len(data):,} bytes")
compressed = gzip.compress(data, compresslevel=9)
ratio = len(compressed) / len(data) * 100
print(f"GZIP compressed: {len(compressed):,} bytes ({ratio:.1f}%)")
return compressed
def authenticate(sock: socket.socket, psk: bytes) -> bool:
"""Perform SHA256 challenge-response authentication with the device."""
# Receive 32-byte nonce from server
nonce = b""
while len(nonce) < 32:
chunk = sock.recv(32 - len(nonce))
if not chunk:
print("ERROR: Connection closed during authentication")
return False
nonce += chunk
# Compute SHA256(nonce || PSK)
h = hashlib.sha256()
h.update(nonce)
h.update(psk)
response = h.digest()
# Send 32-byte response
sock.sendall(response)
# Wait for auth result (1 byte)
result = sock.recv(1)
if not result:
print("ERROR: No authentication response")
return False
if result[0] == 0x06: # ACK
print("Authentication successful.")
return True
elif result[0] == 0x07: # OTA_ERR_AUTH
print("ERROR: Authentication failed — wrong PSK")
return False
else:
print(f"ERROR: Unexpected auth response 0x{result[0]:02X}")
return False
def upload_firmware(host: str, port: int, firmware: bytes, psk: bytes, timeout: float) -> bool:
"""Upload firmware over TCP using the MOTA protocol with PSK authentication."""
fw_crc = crc32(firmware)
fw_size = len(firmware)
print(f"Connecting to {host}:{port}...")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
print("Connected.")
# Step 1: Authenticate
print("Authenticating...")
if not authenticate(sock, psk):
return False
# Step 2: Send 12-byte MOTA header: magic(4) + size(4) + crc32(4)
header = struct.pack("<4sII", b"MOTA", fw_size, fw_crc)
sock.sendall(header)
print(f"Header sent: size={fw_size:,}, CRC32=0x{fw_crc:08X}")
# Wait for ACK (1 byte)
ack = sock.recv(1)
if not ack or ack[0] != 0x06:
error_codes = {
0x02: "Size error",
0x04: "Invalid magic",
0x05: "Update.begin() failed",
}
code = ack[0] if ack else 0xFF
msg = error_codes.get(code, f"Unknown error 0x{code:02X}")
print(f"ERROR: Server rejected header: {msg}")
return False
print("Header accepted. Uploading firmware...")
# Send firmware in 1KB chunks
chunk_size = 1024
sent = 0
start_time = time.time()
while sent < fw_size:
end = min(sent + chunk_size, fw_size)
chunk = firmware[sent:end]
sock.sendall(chunk)
sent = end
# Progress bar
pct = sent * 100 // fw_size
bar_len = 40
filled = bar_len * sent // fw_size
bar = "" * filled + "" * (bar_len - filled)
elapsed = time.time() - start_time
speed = sent / elapsed if elapsed > 0 else 0
sys.stdout.write(f"\r [{bar}] {pct:3d}% {sent:,}/{fw_size:,} ({speed/1024:.1f} KB/s)")
sys.stdout.flush()
elapsed = time.time() - start_time
print(f"\n Transfer complete in {elapsed:.1f}s")
# Wait for final result (1 byte)
print("Waiting for verification...")
result = sock.recv(1)
if not result:
print("ERROR: No response from device")
return False
result_codes = {
0x00: "OK — Update staged, device rebooting",
0x01: "CRC mismatch",
0x02: "Size error",
0x03: "Write error",
0x04: "Magic mismatch",
0x05: "Updater.begin() failed",
0x07: "Auth failed",
0x08: "Timeout",
}
code = result[0]
msg = result_codes.get(code, f"Unknown result 0x{code:02X}")
if code == 0x00:
print(f"SUCCESS: {msg}")
return True
else:
print(f"ERROR: {msg}")
return False
except socket.timeout:
print("ERROR: Connection timed out")
return False
except ConnectionRefusedError:
print(f"ERROR: Connection refused by {host}:{port}")
return False
except OSError as e:
print(f"ERROR: {e}")
return False
finally:
sock.close()
def main():
parser = argparse.ArgumentParser(
description="Upload firmware to Meshtastic RP2350 devices via Ethernet OTA"
)
parser.add_argument("firmware", help="Path to firmware .bin or .bin.gz file")
parser.add_argument("--host", required=True, help="Device IP address")
parser.add_argument(
"--port", type=int, default=4243, help="OTA port (default: 4243)"
)
parser.add_argument(
"--timeout",
type=float,
default=60.0,
help="Socket timeout in seconds (default: 60)",
)
psk_group = parser.add_mutually_exclusive_group()
psk_group.add_argument(
"--psk",
type=str,
help="Pre-shared key as UTF-8 string (default: meshtastic_ota_default_psk_v1!!!)",
)
psk_group.add_argument(
"--psk-hex",
type=str,
help="Pre-shared key as hex string (e.g., 6d65736874...)",
)
args = parser.parse_args()
# Resolve PSK
if args.psk:
psk = args.psk.encode("utf-8")
elif args.psk_hex:
try:
psk = bytes.fromhex(args.psk_hex)
except ValueError:
print("ERROR: Invalid hex string for --psk-hex")
sys.exit(1)
else:
psk = DEFAULT_PSK
print("Meshtastic Ethernet OTA Upload")
print("=" * 40)
firmware = load_firmware(args.firmware)
if upload_firmware(args.host, args.port, firmware, psk, args.timeout):
print("\nDevice is rebooting with new firmware.")
sys.exit(0)
else:
print("\nUpload failed.")
sys.exit(1)
if __name__ == "__main__":
main()
-249
View File
@@ -1,249 +0,0 @@
#!/usr/bin/env bash
# Run native PlatformIO unit tests and emit a single, unambiguous RED/AMBER/GREEN verdict.
#
# Why this exists: PlatformIO reports failures three different ways ([FAILED], :FAIL:,
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause — so naive
# greps produce false greens (see .notes/test-passfail-filter.md). This script encodes the
# correct logic once, and cross-checks the number of suites that actually ran against the
# canonical set in test/ so a suite silently going missing shows up as AMBER, not green.
#
# Usage:
# ./bin/run-tests.sh # run all suites, full RAG + count cross-check
# ./bin/run-tests.sh -f test_utf8 # run one suite (no count cross-check)
# ./bin/run-tests.sh -e native # override env (default: coverage)
# ./bin/run-tests.sh --quiet # only print the final RESULT line
#
# Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER.
#
# The final line is machine-readable, e.g.:
# RESULT: GREEN 19/19 suites passed
# RESULT: AMBER 17/19 suites ran (missing: test_radio test_serial) — all that ran passed
# RESULT: RED test_traffic_management: 1 failed (or: build/crash error)
# RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
# all passed; the coverage build aborts at exit on an ASan/LSan fault — often shown only
# as [ERRORED]/SIGHUP. The script names it and points at running the binary bare.)
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
ENV="coverage"
FILTER=""
QUIET=false
PASSTHRU=()
while [[ $# -gt 0 ]]; do
case "$1" in
-f)
FILTER="$2"
PASSTHRU+=("-f" "$2")
shift 2
;;
-e)
ENV="$2"
shift 2
;;
--quiet)
QUIET=true
shift
;;
*)
PASSTHRU+=("$1")
shift
;;
esac
done
# Locate pio (PATH, then the standard PlatformIO venv).
PIO="$(command -v pio || command -v platformio || echo "$HOME/.platformio/penv/bin/pio")"
if [[ ! -x $PIO ]] && ! command -v "$PIO" >/dev/null 2>&1; then
echo "RESULT: RED pio not found (looked in PATH and ~/.platformio/penv/bin)"
exit 1
fi
LOG="$(mktemp -t meshtest.XXXXXX.log)"
MARKER=""
PROGRESS_PID=""
trap 'rm -f "$LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
# Canonical suite set = the directories in test/. This is the source of truth for
# "what should run"; a filtered run only expects its filtered suite.
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
EXPECTED_COUNT=${#ALL_SUITES[@]}
# Cached object-count for this env, written after each completed build (in the gitignored build
# dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles),
# only a rough upper bound for an incremental run.
BASELINE_FILE=".pio/build/${ENV}/.runtests-objcount"
# Progress trail file (gitignored build dir). ALWAYS written so a backgrounded/piped run can be
# checked mid-build with `tail -f` — that's the whole point: don't fly blind on a 20-min rebuild.
PROGRESS_FILE=".pio/build/${ENV}/.runtests-progress"
# --- Progress heartbeat ------------------------------------------------------
# Emit ONE status line every few seconds: build = objects (re)compiled this run / cached total +
# best-effort ETA; test = suites finished / expected. Appends to $PROGRESS_FILE always (tail it to
# check on a backgrounded run); also live-updates the tty when $5=1 (interactive --quiet). Never
# touches $LOG, which is parsed for the verdict, so piped/CI captures stay clean.
progress_monitor() {
local marker="$1" objtotal="$2" testtotal="$3" pfile="$4" totty="$5" start now el done ran eta line
start=$(date +%s)
while :; do
now=$(date +%s)
el=$((now - start))
if grep -q 'Testing\.\.\.' "$LOG" 2>/dev/null; then
ran=$(grep -cE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" 2>/dev/null)
line=$(printf '[test] %s/%s suites done — %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
else
done=$(find ".pio/build/${ENV}" -name '*.o' -newer "$marker" 2>/dev/null | wc -l)
if ((objtotal > 0 && done > 0)); then
eta=$((objtotal > done ? (objtotal - done) * el / done : 0))
line=$(printf '[build] %d/%d objs — %dm%02ds — ETA ~%dm%02ds' \
"$done" "$objtotal" $((el / 60)) $((el % 60)) $((eta / 60)) $((eta % 60)))
else
# done==0 (incremental: nothing to rebuild yet) or no cached baseline — no ETA yet.
line=$(printf '[build] %d objs compiled — %dm%02ds' "$done" $((el / 60)) $((el % 60)))
fi
fi
printf '%s\n' "$line" >>"$pfile" 2>/dev/null # file trail (always)
[[ $totty == 1 ]] && printf '\r\033[K%s' "$line" >/dev/tty 2>/dev/null # live line (human)
sleep 4
done
}
# Launch the heartbeat for every run. It writes the progress file unconditionally; the live tty
# line only when interactive AND --quiet (where pio's own output is hidden — otherwise pio's
# streamed compile lines already show progress and a \r line would just fight them).
mkdir -p ".pio/build/${ENV}" 2>/dev/null || true
: >"$PROGRESS_FILE" 2>/dev/null || true
MARKER="$(mktemp -t meshtest-mark.XXXXXX)"
TOTTY=0
{ $QUIET && [[ -t 1 ]]; } && TOTTY=1
progress_monitor "$MARKER" "$(cat "$BASELINE_FILE" 2>/dev/null || echo 0)" \
"$([[ -n $FILTER ]] && echo 1 || echo "$EXPECTED_COUNT")" "$PROGRESS_FILE" "$TOTTY" &
PROGRESS_PID=$!
if ! $QUIET; then
echo "Running: $PIO test -e $ENV ${PASSTHRU[*]-} (expecting $EXPECTED_COUNT suites)"
fi
echo "progress: tail -f $PROGRESS_FILE" >&2
# Run pio, tee to log. PIPESTATUS[0] is pio's real exit (NOT tee's).
if $QUIET; then
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" >"$LOG" 2>&1
else
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" 2>&1 | tee "$LOG"
fi
PIO_RC=${PIPESTATUS[0]}
# Stop the heartbeat, clear its line, and cache this build's object total for next time.
if [[ -n $PROGRESS_PID ]]; then
kill "$PROGRESS_PID" 2>/dev/null
wait "$PROGRESS_PID" 2>/dev/null
PROGRESS_PID=""
# Clear the live line only if we were writing one — opening /dev/tty when there is none is
# itself a redirect-open error the trailing 2>/dev/null cannot suppress.
[[ $TOTTY == 1 ]] && printf '\r\033[K' >/dev/tty 2>/dev/null
fi
[[ -d ".pio/build/${ENV}" ]] && find ".pio/build/${ENV}" -name '*.o' 2>/dev/null | wc -l >"$BASELINE_FILE" 2>/dev/null || true
# --- Outcome detection -------------------------------------------------------
# The SAME outcome is spelled differently depending on which layer emitted the line — this is
# the trap that produces false greens (grepping ":PASS" misses pio's "[PASSED]", grepping
# "[FAILED]" misses Unity's ":FAIL:"). So every regex below matches BOTH spellings:
# pass: Unity per-assertion ":PASS" | pio per-suite "[PASSED]" | summary "N succeeded"
# fail: Unity per-assertion ":FAIL:" | pio per-suite "[FAILED]" | summary "M failed"
# error: pio build/crash "[ERRORED]" | Unity "M Failures" | compiler "error:"
# Match \b after :PASS/:FAIL so ":PASSED"/":FAILED" forms are also caught either way.
FAIL_RE=':FAIL\b|\[FAILED\]|\[ERRORED\]|[1-9][0-9]* failed|[0-9]+ Tests [1-9][0-9]* Failures|error:|undefined reference|Segmentation fault|terminate called|SIGHUP|SIGSEGV|SIGABRT'
# Positive proof tests actually ran & passed (absence != success). Accept any pass spelling:
# the per-test/per-suite tokens OR a success summary line.
PASS_RE=':PASS\b|\[PASSED\]|test cases: *[0-9]+ succeeded|[0-9]+ Tests 0 Failures'
# Sanitizer (ASan/LSan/UBSan/TSan) fault signatures. The coverage build is sanitizer-instrumented
# and aborts NON-ZERO at exit on a fault — most often a LeakSanitizer leak — AFTER every test has
# already printed [PASSED]. pio then reports [ERRORED]/SIGHUP with no :FAIL: anywhere, so it
# masquerades as a phantom "N-1 of N succeeded". See .notes/test-passfail-filter.md.
# Match only real FAULT lines, never the benign "AddressSanitizer: failed to intercept '...'"
# startup noise that prints on every sanitizer run (it'd mislabel a normal [FAILED] as a leak).
# Formats per LLVM/Google sanitizer docs: ASan/LSan emit "==PID==ERROR: <San>: ...", UBSan emits
# "file:line:col: runtime error: ...", TSan emits "WARNING: ThreadSanitizer: ..."; all close with
# a "SUMMARY: <San>: ..." line (LSan-under-ASan reports its SUMMARY as "AddressSanitizer").
SAN_RE='(ERROR|WARNING): (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|SUMMARY: (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|Direct leak of|Indirect leak of|detected memory leaks|heap-use-after-free|heap-buffer-overflow|stack-buffer-overflow|attempting double-free|LeakSanitizer has encountered a fatal error|runtime error:'
# Suites that produced a per-suite verdict. pio emits "coverage:test_x [PASSED|FAILED|ERRORED]";
# a SKIPPED suite (hardware-only on native) is "accounted for" too, so it doesn't read as missing.
mapfile -t RAN_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" |
sed -E "s/^${ENV}:(test_[a-z0-9_]+) .*/\1/" | sort -u)
RAN_COUNT=${#RAN_SUITES[@]}
# Suites pio explicitly skipped (don't count these as "missing" in the canonical cross-check).
mapfile -t SKIPPED_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+.*\bSKIPPED\b" "$LOG" |
grep -oE "test_[a-z0-9_]+" | sort -u)
verdict_red() {
local detail bin
detail="$(grep -nE '\[FAILED\]|:FAIL:|\[ERRORED\]' "$LOG" | head -3 | sed 's/^/ /')"
echo ""
echo "RED — failures detected:"
[[ -n $detail ]] && echo "$detail"
grep -E 'test cases:' "$LOG" | tail -1 | sed 's/^/ /'
# Path to the test binary for the "run it bare" hint. For native/coverage the test program is
# the env executable (e.g. .pio/build/coverage/meshtasticd), NOT a file named 'program'.
bin="$(find ".pio/build/${ENV}" -maxdepth 1 -type f -executable ! -name '*.so' 2>/dev/null | head -1)"
[[ -z $bin ]] && bin=".pio/build/${ENV}/<program> (build it first: $PIO test -e ${ENV} ${FILTER:+-f $FILTER} --without-testing)"
# Sanitizer fault (ASan/LSan/UBSan/TSan): name the real cause instead of "build/crash error".
if grep -qE "$SAN_RE" "$LOG"; then
grep -nE "$SAN_RE" "$LOG" | head -4 | sed 's/^/ /'
echo " -> sanitizer fault: if every test above is PASS, this is an exit-time abort, not a failed assertion."
echo " -> read the full report by running the binary BARE (gdb hides it via ptrace): ./$bin 2>&1 | tail -40"
echo "RESULT: RED sanitizer fault — $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
exit 1
fi
# All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the
# sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a
# sanitizer fault — point at how to surface it rather than calling it a generic crash.
if grep -qE "$PASS_RE" "$LOG" && grep -qE '\[ERRORED\]|SIGHUP|SIGABRT' "$LOG" && ! grep -qE ':FAIL\b|\[FAILED\]' "$LOG"; then
echo " -> all tests passed but the process aborted at EXIT — likely an ASan/LSan fault whose report"
echo " the runner swallowed (commonly shown as SIGHUP). Run the binary BARE to see it: ./$bin 2>&1 | tail -40"
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)"
exit 1
fi
echo "RESULT: RED $(grep -oE '[0-9]+ failed' "$LOG" | tail -1 || echo 'build/crash error')"
exit 1
}
# RED: pio non-zero, any failure marker, or no positive summary at all (build died early).
if [[ $PIO_RC -ne 0 ]] || grep -qE "$FAIL_RE" "$LOG"; then
verdict_red
fi
if ! grep -qE "$PASS_RE" "$LOG"; then
echo ""
echo "RESULT: RED no success summary found (build error / no tests ran?) — see log"
exit 1
fi
# AMBER: everything that ran passed, but (full run only) a canonical suite neither ran NOR was
# explicitly skipped — i.e. it silently went missing. SKIPPED suites are accounted for.
ACCOUNTED_COUNT=$((RAN_COUNT + ${#SKIPPED_SUITES[@]}))
if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
missing=()
for s in "${ALL_SUITES[@]}"; do
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s")
done
echo ""
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) — all that ran passed"
exit 2
fi
# GREEN.
if [[ -n $FILTER ]]; then
echo "RESULT: GREEN ${RAN_COUNT} suite(s) passed (filtered: $FILTER)"
else
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed"
fi
exit 0
-54
View File
@@ -1,54 +0,0 @@
{
"build": {
"arduino": {
"ldscript": "nrf52840_s140_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DNRF52840_XXAA",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x4405"],
["0x239A", "0x0029"],
["0x239A", "0x002A"],
["0x239A", "0x0071"]
],
"usb_product": "HT-n5262",
"mcu": "nrf52840",
"variant": "heltec_mesh_tower_v2",
"variants_dir": "variants",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS140",
"sd_name": "s140",
"sd_version": "6.1.1",
"sd_fwid": "0x00B6"
},
"bootloader": {
"settings_addr": "0xFF000"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52840_xxAA",
"onboard_tools": ["jlink"],
"svd_path": "nrf52840.svd",
"openocd_target": "nrf52840-mdk-rs"
},
"frameworks": ["arduino"],
"name": "Heltec MeshTower V2 (Adafruit BSP)",
"upload": {
"maximum_ram_size": 248832,
"maximum_size": 815104,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
"use_1200bps_touch": true,
"require_upload_port": true,
"wait_for_upload_port": true
},
"url": "https://heltec.org",
"vendor": "Heltec"
}
+50
View File
@@ -0,0 +1,50 @@
{
"build": {
"arduino": {
"ldscript": "nrf52832_s132_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DNRF52832_XXAA -DNRF52",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x8029"],
["0x239A", "0x0029"],
["0x239A", "0x002A"],
["0x239A", "0x802A"]
],
"usb_product": "Feather nRF52832 Express",
"mcu": "nrf52832",
"variant": "WisCore_RAK4600_Board",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS132",
"sd_name": "s132",
"sd_version": "6.1.1",
"sd_fwid": "0x00B7"
},
"zephyr": {
"variant": "nrf52_adafruit_feather"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52832_xxAA",
"svd_path": "nrf52.svd",
"openocd_target": "nrf52840-mdk-rs"
},
"frameworks": ["arduino", "zephyr"],
"name": "Adafruit Bluefruit nRF52832 Feather",
"upload": {
"maximum_ram_size": 65536,
"maximum_size": 524288,
"require_upload_port": true,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
},
"url": "https://www.adafruit.com/product/3406",
"vendor": "Adafruit"
}
@@ -1,277 +0,0 @@
# LoRa Region → Preset Compatibility — Client Implementation Spec
**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first,
Apple second, then web/python) · **Firmware side:** implemented in `firmware`
(`FromRadio.region_presets`, see below).
> This document lives in the firmware repo while the feature is developed. It is meant to
> graduate to `meshtastic/protobufs` (and/or the docs site) alongside the upstream protobuf
> PR that reserves `FromRadio` field **19**.
---
## 1. Why this exists
For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal
in every region** — narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and
the 2.4 GHz band each accept only a specific subset of presets. The firmware already
enforces this internally (it clamps or rejects illegal combinations), but until now a client
had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI
and only discover the problem after the device silently corrected it.
This feature has the firmware **declare the legal region→preset combinations** to the client
during the `want_config` handshake, so the client UI can constrain the preset picker to the
valid set for the currently selected region (and warn about licensed-only bands). It is
purely advisory metadata — the firmware remains the source of truth and still
validates/clamps on its own.
---
## 2. Protocol additions
Three new messages in `meshtastic/mesh.proto`, plus one new `FromRadio` oneof variant.
### 2.1 `FromRadio.region_presets` (field 19)
```proto
message FromRadio {
uint32 id = 1;
oneof payload_variant {
// ... fields 2..18 unchanged ...
LoRaRegionPresetMap region_presets = 19;
}
}
```
### 2.2 Messages
```proto
// A distinct set of legal modem presets shared by one or more LoRa regions.
message LoRaPresetGroup {
repeated Config.LoRaConfig.ModemPreset presets = 1; // legal presets for this group
Config.LoRaConfig.ModemPreset default_preset = 2; // always one of `presets`
bool licensed_only = 3; // ham/amateur band → warn/gate
}
// Associates a single LoRa region with its preset group (by index).
message LoRaRegionPresets {
Config.LoRaConfig.RegionCode region = 1;
uint32 group_index = 2; // index into LoRaRegionPresetMap.groups
}
// The full map, delivered grouped to fit one FromRadio packet.
message LoRaRegionPresetMap {
repeated LoRaPresetGroup groups = 1; // each distinct preset list
repeated LoRaRegionPresets region_groups = 2; // every known region → a group index
}
```
### 2.3 Why grouped (and the size envelope clients should respect)
A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions
share one identical preset list (the "standard" 9-preset list), so the map is delivered
**grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every
known region to one of those groups by index. This keeps the encoded size additive
(`groups` + `region_groups`) rather than multiplicative, well under the cap.
nanopb (firmware) array bounds — clients do **not** need to enforce these, but they bound
what you can receive:
| field | max_count |
| ----------------------------------- | ------------------------------------ |
| `LoRaRegionPresetMap.groups` | 8 |
| `LoRaRegionPresetMap.region_groups` | 38 (= number of `RegionCode` values) |
| `LoRaPresetGroup.presets` | 11 |
---
## 3. When it is delivered
`region_presets` is sent **once** during the `want_config` handshake, as a single
`FromRadio` message, in this position:
```text
my_info → (deviceuiConfig) → node_info(self) → metadata → region_presets → channel… → config… → moduleConfig… → node_info(others)… → fileInfo… → config_complete_id → (live packets)
```
i.e. **immediately after `metadata` and before the first `channel`**.
- It is included for a normal full `want_config` and for the **config-only** nonce.
- It is **omitted** for the **nodes-only** nonce (that path skips metadata/config entirely).
- A client must **not** assume it always arrives (see §5).
---
## 4. Decoding into a usable lookup
Flatten the grouped wire form into `Map<RegionCode, RegionPresetInfo>`:
```text
struct RegionPresetInfo { Set<ModemPreset> presets; ModemPreset default; bool licensedOnly }
fun decode(map: LoRaRegionPresetMap): Map<RegionCode, RegionPresetInfo> {
result = {}
for (rg in map.region_groups) {
if (rg.group_index >= map.groups.size) continue // defensive: malformed/forward data
g = map.groups[rg.group_index]
result[rg.region] = RegionPresetInfo(
presets = g.presets.toSet(),
default = g.default_preset,
licensedOnly = g.licensed_only)
}
return result
}
```
Persist this map alongside the rest of the downloaded config so the LoRa config screen can
read it synchronously.
---
## 5. Semantics & rules (the load-bearing part)
These rules are what keep the UX correct across firmware versions. Implement all of them.
1. **Absent region ⇒ no constraint.** If a `RegionCode` does not appear in `region_groups`,
the client has _no_ compatibility info for it and **must not restrict** its preset
choices (fall back to allowing the full `ModemPreset` list). This happens for a handful
of `RegionCode` enum values that have no firmware band table entry (today: `EU_874`,
`EU_917`, `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`).
2. **Absent message ⇒ no constraint.** Firmware older than 2.8 never sends `region_presets`.
New clients **must** tolerate the message being absent entirely and keep their existing
(unconstrained) behavior. Do not block the config screen waiting for it.
3. **`default_preset`** is always a member of that group's `presets`. Use it to pre-select a
preset when the user switches to a region whose valid set does not include the currently
selected preset (instead of leaving an illegal selection or guessing).
4. **`licensed_only`** marks ham/amateur bands. Surface a warning or gate (the firmware also
requires the operator's `is_licensed` flag for these regions; coordinate the two so the
user isn't allowed to pick a licensed band without acknowledging licensing).
5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions
(`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a
preset that belongs to a sibling's list, the firmware **swaps the region** rather than
rejecting the preset. Consequence for clients: **do not assume the region is immutable
across a preset change** — after an admin config write, re-read the resulting
`LoRaConfig` and reflect the (possibly changed) region back into the UI.
6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps
on its own. The map exists to prevent the user from _selecting_ an illegal combo; it is
not a security or correctness boundary.
---
## 6. UI/UX recommendations
- In the LoRa config screen, when a region is selected, **filter/enable the modem-preset
picker to that region's `presets`** (when `use_preset`/`use_modem_preset` is on).
- If the current preset is not in the newly selected region's set, switch the selection to
that region's `default_preset`.
- Show a **licensed badge / confirmation** for regions where `licensed_only == true`.
- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2),
render the full preset list as before — never show an empty picker.
---
## 7. Forward / backward compatibility
- **Old clients, new firmware:** an unknown `FromRadio` oneof variant (field 19) is ignored
by protobuf/nanopb decoders; the relative ordering of the known messages is unchanged, so
existing apps are unaffected.
- **New clients, old firmware:** message simply never arrives → treat as "no constraints"
(§5.2).
- **Enum growth:** new `RegionCode`/`ModemPreset` values may appear over time. Decoders
should pass through unknown enum values rather than crashing; an unknown region in
`region_groups` is harmless (the client just won't have a localized name for it).
---
## 8. Platform notes
> Verified against the `main` branch of each repo. Both have been refactored away from
> older layouts; re-pin file paths against a specific commit if you need them durable.
### 8.1 Android — `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP)
- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in
`gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated
package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new
published `org.meshtastic:protobufs` release**, then bumping that one version string.
- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a
`payloadVariantCase` enum — each arm is a **nullable field**. Handle the new variant in
`FromRadioPacketHandlerImpl.handleFromRadio(...)`
(`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a
`regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler
(mirror `handleLocalMetadata` / `handleConfigComplete`).
- **State holder:** expose the decoded map from `RadioConfigRepository` /
`RadioConfigRepositoryImpl` as a `Flow` (mirroring `localConfigFlow`/`channelSetFlow`),
consumed by `feature/settings/.../radio/RadioConfigViewModel.kt`.
- **UI:** the region & preset dropdowns are `DropDownPreference`s in
`feature/settings/.../radio/component/LoRaConfigItemList.kt` (public composable
`LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected
`RegionInfo`'s entry in the map.
### 8.2 Apple — `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs`
(`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git
submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs`
submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule
pointer. (No published-artifact dependency — Apple can regenerate from any commit.)
- **Dispatch:** `AccessoryManager.processFromRadio(_:)`
(`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real
`switch decodedInfo.payloadVariant { … }` — add a `.regionPresets` case, with the handler
in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`).
- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via
`MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection
model) so the LoRa view can read it.
- **UI:** `Meshtastic/Views/Settings/Config/LoRaConfig.swift` (`struct LoRaConfig: View`)
has the `Picker("Region", …)` (`RegionCodes.userSelectable`) and `Picker("Presets", …)`
(`ModemPresets.userSelectable`, gated on `usePreset`). Filter the presets picker by the
selected region's entry. Enums live in `Meshtastic/Enums/LoraConfigEnums.swift`.
### 8.3 Other clients
- **python (`meshtastic` / Meshtastic-python)** and **web** consume the published protobufs;
they will see `region_presets` once their protobuf dependency includes field 19, and can
ignore it until then (it decodes as an unknown field).
---
## 9. Reference payload (current firmware table)
For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups**. Group
indices are assigned in region-table order (first region to use a profile creates its group),
so they are stable as listed here:
| group_index | default_preset | licensed_only | presets |
| ----------------------- | -------------- | ------------- | -------------------------------------------------------------------------------------------------------------- |
| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO |
| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE |
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW |
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW |
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW |
`region_groups` (region → group_index):
| group | regions |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 |
| 1 | EU_868 |
| 2 | EU_866 |
| 3 | EU_N_868 |
| 4 | ITU1_2M, ITU2_2M, ITU3_2M |
| 5 | ITU2_125CM |
> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups
> because they differ in `licensed_only`. Decoders must key on the group, not on the preset
> list, to preserve the licensing flag.
>
> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`,
> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`.
This table is generated from the firmware's region table at runtime; treat the firmware as
authoritative and these values as the expected snapshot for the 2.8 table.
-456
View File
@@ -1,456 +0,0 @@
# NextHop direct-message reliability on dense meshes — findings & plan
**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop`
**Date:** 2026-06-13
**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`)
**Constraint:** No over-the-air / wire-format changes — `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
This document captures the analysis and the proposed mitigations so the work can be
continued on this branch by anyone. It is intentionally code-grounded (file:line
references throughout) and standalone — you should not need the original investigation
context to pick it up.
---
## TL;DR
NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline
cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte
(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50100. But
there are **other, equally important issues**: that single byte is trusted blindly at
five different code sites, learned routes **never decay**, routes are learned from the
**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious
rebroadcasts **amplify congestion** exactly when the mesh is busy.
Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't
trust a byte that doesn't map to a unique reachable neighbor — flood instead") plus
**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four
mitigations, M1M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
DM that today silently misroutes or black-holes instead falls back to managed flooding
(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are
unchanged.
---
## How NextHop routing works today (mechanics)
Inheritance chain: `Router``FloodingRouter``NextHopRouter``ReliableRouter`.
**The single-byte identifiers.** Both routing bytes come from one helper:
```cpp
// src/mesh/NodeDB.h:255
uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); }
```
It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it
never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`,
`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are
`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned
route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte
(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`).
**Sending a DM**`NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer).
2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`):
look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer
byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood).
**Relaying**`NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or**
`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop)
(`:147`). Each node only ever compares against **its own** byte.
**Learning**`NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of
the original packet (validated via `PacketHistory::checkRelayers`), set
`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned
from the **reverse** path's relayer.
**Retransmission / fallback**`NextHopRouter::doRetransmissions`
(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial
- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry
(`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet
**and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing
comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel
utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`).
**Dedup / relayer history**`PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by
`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in
`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against
that array.
---
## Root-cause analysis
### 1. The single byte is trusted blindly at five sites (the birthday problem)
| # | Site | File:line | Failure on collision |
| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. |
| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. |
| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. |
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins — non-deterministic; can preserve hops for the wrong relay (hop leak). |
| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. |
Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes,
> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime.
### 2. Stale routes never decay
The learned `next_hop` byte is cleared only on the **current DM's** last retry
(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is
still trusted on the **next** DM's first attempt — which on a congested mesh is also the
slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget
drains, then a late flood. Intermediate nodes hold stale routes indefinitely.
### 3. Reverse-path (asymmetric-link) learning
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) — the
**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can
be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad
hop, so the route **flaps** back to the bad value even after a failure reset.
### 4. Congestion amplification
Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window
grows with channel utilization, so retransmit intervals **lengthen** exactly when the
mesh is busy. The 3-try reliable budget can then expire before delivery. On dense
meshes, efficiency _is_ reliability.
### Note: pubkey-derived node numbers (develop / 2.8) — does not change the plan
develop derives the node number from the public key:
`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key
change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the
plan rather than changing it:
- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last
byte is uniformly distributed over 256 values. Derivation adds no wire bits.
- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could
renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte
collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_
necessary.
- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one
identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking
it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's
candidate gate already skips — so key rotation can't pollute resolution.
- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot
recover which full node number a colliding value meant — so "detect ambiguity → flood"
remains the correct strategy.
---
## Proposed mitigations
Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's
direct neighbors / plausible relays, not the whole mesh.** That candidate set is small
(typically 515), so a byte usually resolves unambiguously there; when it doesn't, fall
back to the _safe_ behavior (flood / decrement / don't-learn).
### M1 — Ambiguity-aware last-byte resolution (new NodeDB primitive)
New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp`
(near `getMeshNode`, ~2936):
```cpp
enum class LastByteResolution : uint8_t { None, Unique, Ambiguous };
struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; };
// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates.
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE).
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
```
- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`,
the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`,
and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`).
- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid).
- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`,
`num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match
`getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`).
- **Relevance gate:**
- `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0`
**and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`.
- `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct
neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}.
- **No tie-break.** A collision must return `Ambiguous` — picking "best SNR" would
resurrect the silent-misroute bug. (Deliberate non-goal; document in code.)
New constant in `src/mesh/MeshTypes.h` (near line 44):
`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`).
### M2 — Only route on bytes that resolve to a unique, reachable neighbor
In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon
check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique,
currently-fresh direct neighbor**; else flood:
```cpp
if (node->next_hop != relay_node) {
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique) return node->next_hop;
LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor");
return std::nullopt;
}
```
This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It
applies to originating, relaying, and retrying, since all route through `getNextHop`.
Apply M1's safe fallback at the other sites:
- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on
`resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't
learn (leave route unset → flood).
- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins"
loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the
resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement**
(safe). Net: removes one full DB scan, adds one resolver scan (wash).
**Left unchanged, by design (document why in code):**
- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks
(`ReliableRouter.cpp:127`): a node matches its **own** byte — no DB resolution helps. A
remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2
shrink the blast radius by reducing how often an ambiguous byte is ever stored or
originated; a true fix needs a wider field (out of scope). **This is the one residual
the plan cannot fully close.**
- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally
byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened.
Add a one-line comment; do not change.
### M3 — Route freshness / failure memory (RAM table on NextHopRouter)
A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s
reuse-oldest discipline (not an unbounded map) to cap RAM.
`src/mesh/NextHopRouter.h` (near `pending`, line 99):
```cpp
struct RouteHealth {
NodeNum dest = 0; // 0 == empty slot
uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware
uint8_t consecutiveFailures = 0;
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to
};
static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth,
// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth
```
Policy:
| Constant | Value | Rationale |
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. |
| `ROUTE_FAILURE_THRESHOLD` | 3 | 12 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`.
All age math uses **unsigned subtraction** (rollover-safe, matching
`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now".
Wiring (as built — `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear
`node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No
record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor
gate still applies.
- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then
`noteRouteLearned(p->from, p->relay_node, millis())` — resets `consecutiveFailures`
**only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes
`learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK
merely passing through us is not proof that _we_ delivered, and resetting failures there
would reintroduce the asymmetric flap.)
- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the
point a directed delivery has gone un-ACKed for both originator and intermediate) →
`noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately
do **not** `clearRouteHealth` here: keeping the record is what lets the failure count
accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out.
- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())`
(an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and
refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record
exists, so flood-only destinations never pollute the table.
**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of
the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter);
`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The
only place that erases a health record is the `getNextHop` decay path; the retransmission
path leaves it intact so the counter survives a reverse-path re-learn.
### M4 — Earlier flood for unverified routes (gated, off by default)
Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define
lives in `NextHopRouter.h` and must be flipped to measure:
`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`.
In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified**
(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and
flood on this attempt instead of spending another directed try. A **verified** route
(record present, `consecutiveFailures == 0`, within TTL — i.e. recently ACKed) takes the
unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off:
airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on
one we already distrust. Off by default precisely so it can be A/B-measured on the
simulator before broad enable.
---
## Files to modify
| File | Change |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` |
| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` |
| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` |
| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 |
| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check |
| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` |
| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) |
**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers,
`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and
`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`).
---
## Edge cases
- **`0x00``0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both
sides, so a `…00` node and a `…FF` node correctly collide on `0xFF``Ambiguous`. Test
explicitly.
- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0`
(`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn
(correct).
- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a
Unique neighbor for M2); admitted to the lenient gate only via favorite/router role.
Safe; self-corrects once `hops_away` is learned.
- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`;
`getNextHop` already early-returns for broadcast.
- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd
match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a
future 256-entry last-byte index is the optimization (not now — RAM).
---
## Verification (all tiers)
### 1. Native unit tests — new `test/test_nexthop_routing/test_main.cpp`
`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`.
Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is
testable without a clock mock.
- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale /
strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self /
skips-ignored / **`0x00``0xFF` collision** / early-exit.
- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt,
split-horizon (relay==next_hop)→nullopt, broadcast→nullopt.
- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap),
failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**,
re-learn-new-hop resets, LRU eviction bound, clear.
- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**;
decrement when the resolved node is not a favorite.
- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop`
returns the stored byte unchanged (proves no happy-path change).
- Re-run `test_packet_history` and `test_hop_scaling` for no regression.
### 2. portduino SimRadio simulator
`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node**
path the 2-device bench can't reach. Line topology A — B — C: establish A→C (B learns a
directed route), stop B relaying that dest, confirm A re-discovers via flood within
`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible
via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4
(attempts-to-delivery, total airtime).
### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop)
- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` — **the multi-hop validator
for this work** (added on this branch). Self-discovers an A — relay — C line, asserts a
directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts
delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true
multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range).
- `mcp-server/tests/mesh/test_direct_with_ack.py` — happy-path regression: a fresh/unique
route still delivers a want_ack DM on the first/second try (M4's gate must keep this
green).
- `mcp-server/tests/mesh/test_peer_offline_recovery.py` — 2-device recovery validator: peer
off mid-conversation then back. Must stay green and ideally recover in fewer attempts.
### 4. Build / format sanity
native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to
confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into
production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table.
---
## Verification status (as built on `nexthop-redux`)
| Tier | What ran | Result |
| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- |
| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 |
| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 |
| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 |
| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link |
| Format | trunk `clang-format@16.0.3` | ✅ no issues |
| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash |
**Pending (environment-blocked, not yet run):**
- **Multi-hop ABC recovery sim** — the `simulator/` broker hub is **not git-tracked**
(only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other
without it. The intermediate-node failure-count path and the M4 A/B therefore have unit
coverage of their logic but no end-to-end multi-node run yet.
- **Hardware / multi-hop tier** — a committable bench test now exists:
`mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real
multi-hop pair (A — relay — C), asserts a directed DM is delivered across the relay, and
asserts delivery recovers after the relay is power-cycled (the M3 path). It
`pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF
range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the
NextHop path is genuinely exercised. Collected + verified to skip without hardware;
not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py`
remain the 2-device happy-path/recovery regressions.
---
## Risks & limitations
- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2
only shrink its frequency.
- **Dense meshes flood DMs more often** — intended (a flooded DM arrives; a mis-unicast one
black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on
very dense meshes.
- **M4 airtime** if the gate is too loose → default conservative + compile-gated +
simulator A/B before broad enable.
- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight.
- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL
backstop bounds it. Per-hop failure history is future work (more RAM).
---
## How to continue this work (commit sequencing)
Each step is independently testable; land them as separate commits.
1. **M1 resolver + unit tests**`NodeDB` only; no behavior change until wired. Lands the
`resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix.
2. **M2 + wiring + tests**`getNextHop` strict gate, learning gate, favorite-router
preservation rewrite. Adds the `getNextHop` and site-4 tests.
3. **M3 health table + decay + tests** — RAM `RouteHealth` table, decay-on-read, failure/
success accounting, reconciliation with the existing last-retry reset. Adds the
route-health unit tests and the simulator recovery check.
4. **M4 gated tuning** — early-flood-on-unverified behind the compile flag; simulator A/B
and hardware regression.
Reference plan (with the same content) was developed at
`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this
in-repo doc is the canonical handoff copy.
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821)
#
# Whole-image LTO for nrf52840 (~-60KB; ~-23KB beyond src-only LTO), EXCEPT the objects
# that own interrupt/exception handlers.
#
# Every ISR is referenced only from the assembly vector table (gcc_startup_nrf52840.S),
# which LTO cannot see -> whole-program LTO judges the handlers dead, removes them, and
# the weak `b .` Default_Handler stubs prevail -> the IRQ lands in an infinite loop and the
# chip hangs (or the peripheral silently stalls). Compiling the handler-bearing objects
# WITHOUT LTO lets ordinary linking keep the strong handlers; everything else stays LTO'd:
# - framework core (/FrameworkArduino/, /cores/nRF5/): every nrfx ISR + the FreeRTOS
# SVC/PendSV port.
# - TinyUSB nrf port (Adafruit_TinyUSB_nrf.cpp): USBD_IRQHandler (USB data path).
# - library .cpp files that own a vector ISR (would otherwise be silently dropped):
# bluefruit.cpp -> SD_EVT/SWI2_EGU2 (SoftDevice BLE-event delivery -- advertising
# hangs without it)
# Wire_nRF52.cpp -> SPIM0/TWIM0 + SPIM1/TWIM1 (interrupt-driven I2C/SPI)
# PDM.cpp -> PDM_IRQHandler (PDM microphone)
# RotaryEncoder.cpp -> QDEC_IRQHandler (hardware quadrature/rotary encoder)
#
# A post-link guard (bottom of this file) fails the build if a critical handler was dropped
# anyway -- so a future deps bump or a new ISR-owning library becomes a red build, not a field
# hang. To hunt a dropped ISR by hand: nm the .elf for `_IRQHandler$` symbols marked `W`, then
# grep the libs/framework for who defines them.
#
# HW-validated: RAK4631 (SX1262) + muzi-base (LR1121).
import glob
import os
Import("env")
env.Append(LINKFLAGS=["-flto", "-flto-partition=1to1"])
# The -fno-lto re-compiles below run with the global env, which lacks the framework's
# bundled-library include dirs -- and those libs cross-include each other (Wire pulls in
# Adafruit_TinyUSB.h, which pulls in SPI.h, ...). Add every bundled-lib dir (+ its src/) so
# the re-compiles resolve without chasing headers one at a time.
_fw = env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") or ""
_extra_inc = []
for _d in sorted(glob.glob(os.path.join(_fw, "libraries", "*"))):
if os.path.isdir(_d):
_extra_inc.append(_d)
if os.path.isdir(os.path.join(_d, "src")):
_extra_inc.append(os.path.join(_d, "src"))
FRAMEWORK = ("/FrameworkArduino/", "/cores/nRF5/")
USB_ISR = "Adafruit_TinyUSB_nrf" # USBD_IRQHandler
# Library .cpp files that define vector-table ISRs (the rest of their lib stays LTO'd):
LIB_ISR = ("/bluefruit.cpp", "/Wire_nRF52.cpp", "/PDM.cpp", "/RotaryEncoder.cpp")
def _no_lto(node):
try:
path = node.get_abspath()
except Exception:
path = str(node)
path = path.replace(
"\\", "/"
) # normalize Windows backslashes so matches work cross-platform
if (
USB_ISR in path
or any(s in path for s in FRAMEWORK)
or any(s in path for s in LIB_ISR)
):
return env.Object(
node,
CCFLAGS=env["CCFLAGS"] + ["-fno-lto"],
CPPPATH=env["CPPPATH"] + _extra_inc,
)
return node
env.AddBuildMiddleware(_no_lto)
# --- post-link guard: catch a dropped ISR handler at build time (CI footgun protection) ----
# After every link, fail the build if one of these critical vector-table handlers resolved to
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
# target, so this runs on every PR automatically. If a board deliberately stops using one of
# these, edit the tuples on purpose.
_REQUIRED_STRONG = (
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
"RTC1_IRQHandler", # FreeRTOS scheduler tick
)
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
_REQUIRED_STRONG_USB = (
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
)
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
if not os.path.isfile(_NM):
_NM = "arm-none-eabi-nm" # fall back to PATH
def _assert_isr_handlers_survived(source, target, env):
import subprocess
import sys
try:
# Resolve the ELF at build time; target[0] is the buildprog alias, not the file.
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
out = subprocess.check_output([_NM, elf], universal_newlines=True)
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
print("nrf52_lto: WARNING - ISR-handler guard skipped (nm failed: %s)" % exc)
return
# nm line: "<addr> <type> <symbol>". type 'T'/'t' = strong (good); 'W'/'w' = weak stub.
kind = {}
for line in out.split("\n"):
f = line.split()
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
kind[f[-1]] = f[-2]
required = list(_REQUIRED_STRONG)
defines = [
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
]
if "USE_TINYUSB" in defines:
required += _REQUIRED_STRONG_USB
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
if dropped:
sys.stderr.write(
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
"Each resolved to the weak Default_Handler stub, so the chip hangs when that IRQ\n"
"fires. Compile the .cpp that defines the handler with -fno-lto by adding it to\n"
"LIB_ISR in extra_scripts/nrf52_lto.py. Find the owner of FOO_IRQHandler with:\n"
" grep -rl FOO_IRQHandler <framework-arduinoadafruitnrf52>/{libraries,cores}\n\n"
% ", ".join(dropped)
)
from SCons.Script import Exit
Exit(1) # canonical SCons build-abort -> red build
print(
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
)
# Attach to the phony "buildprog" alias, NOT the .elf file node: SCons can skip a post-action
# on a file target during an incremental relink (observed), but the buildprog alias runs every
# build -- so the guard fires on local incremental rebuilds and clean CI builds alike.
env.AddPostAction("buildprog", _assert_isr_handlers_survived)
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
#
# Post-link guard for the warm-node-store raw-flash region on nRF52840.
#
# The 3 app-region pages below LittleFS (0xEA000-0xED000, reclaimed by whole-image
# LTO) are reserved for the WarmNodeStore record-ring (see WarmNodeStore.h). Our
# linker scripts (nrf52840_s140_v6.ld and nrf52840_s140_v7.ld) cap the image at
# 0xEA000, but boards on the framework-default script (FLASH ending at 0xED000) could
# silently place code in those pages — the first warm-store save would then brick the
# device. This turns that into a build failure.
#
# Image flash end = __etext + sizeof(.data) (loaded at LMA __etext); symbols from
# the framework's nrf52_common.ld.
import os
Import("env")
WARM_REGION_BASE = 0xEA000 # keep in sync with WARM_FLASH_REGION_BASE in WarmNodeStore.h (3 x 4 KB record-ring)
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
if not os.path.isfile(_NM):
_NM = "arm-none-eabi-nm" # fall back to PATH
def _assert_warm_region_clear(source, target, env):
import subprocess
import sys
try:
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
out = subprocess.check_output([_NM, elf], universal_newlines=True)
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
print("nrf52_warm_region: WARNING - guard skipped (nm failed: %s)" % exc)
return
syms = {}
for line in out.split("\n"):
f = line.split()
if len(f) >= 3 and f[-1] in ("__etext", "__data_start__", "__data_end__"):
syms[f[-1]] = int(f[0], 16)
if len(syms) != 3:
print("nrf52_warm_region: WARNING - guard skipped (linker symbols not found)")
return
flash_end = syms["__etext"] + (syms["__data_end__"] - syms["__data_start__"])
if flash_end > WARM_REGION_BASE:
sys.stderr.write(
"\n*** nrf52 warm-region guard: image ends at 0x%X, past the reserved "
"warm-store region at 0x%X ***\n"
"The 12 KB region at 0xEA000 holds the WarmNodeStore record-ring; a warm-store\n"
"save would overwrite this firmware's tail. Shrink the image, or shrink/move\n"
"the region (WARM_FLASH_REGION_BASE in src/mesh/WarmNodeStore.h, the FLASH\n"
"LENGTH in src/platform/nrf52/nrf52840_s140_v6.ld and _v7.ld, and this guard).\n\n"
% (flash_end, WARM_REGION_BASE)
)
from SCons.Script import Exit
Exit(1)
print(
"nrf52_warm_region: guard OK -- image ends at 0x%X, %d KB clear of the warm region"
% (flash_end, (WARM_REGION_BASE - flash_end) // 1024)
)
# Attach to the phony "buildprog" alias (not the .elf node) so the guard runs
# on incremental relinks too -- same reasoning as nrf52_lto.py's guard.
env.AddPostAction("buildprog", _assert_warm_region_clear)
@@ -1,347 +0,0 @@
"""Multi-hop NextHop directed-message delivery + relay-recovery (bench test).
This is the hardware/tier-3 validator for the NextHop DM reliability work
(see `docs/nexthop-routing-reliability.md`). The unit suite
`test/test_nexthop_routing` covers the routing *logic* exhaustively; this test
covers the *end-to-end* multi-hop behavior that only a real (or RF-separated)
mesh exercises:
* a directed DM that must traverse a relay is delivered (next_hop routing +
the M1/M2 ambiguity gate + M3 route learning all engage), and
* when the established relay drops and returns, delivery recovers rather than
black-holing (the M3 stale-route decay / re-learn path).
TOPOLOGY REQUIREMENT — why this usually SKIPS:
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
Three co-located radios all hear each other, so A→C is a single direct hop and
next_hop never engages. To run this test the bench must be a *line* — A — B — C
— with the endpoints out of each other's direct RF range (physical distance or
attenuators). The `multihop_topology` fixture detects this automatically: it
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
file is safe to commit and run anywhere — it only *asserts* when the topology
genuinely requires a relay.
REQUIREMENTS:
* ≥3 baked devices. The default hub profile is 2 roles (nrf52, esp32s3); add a
third via `--hub-profile=path/to/hub.yaml` (see conftest `hub_profile`).
* The relay-recovery test additionally needs uhubctl + a power-controllable
relay port (same gate the other power tests use).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from tests import _power
from tests._port_discovery import resolve_port_by_role
from ._receive import ReceiveCollector, nudge_nodeinfo, nudge_nodeinfo_port
def _hops_away(rec: dict[str, Any]) -> int | None:
"""Read a node's hop distance from a `nodesByNum` entry, tolerating either
the camelCase (`hopsAway`) or snake_case (`hops_away`) spelling depending on
the meshtastic-python version."""
for key in ("hopsAway", "hops_away"):
val = rec.get(key)
if isinstance(val, int):
return val
return None
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
"""Flood a fresh NodeInfo from every node so the whole mesh (including
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
distances. Best-effort — a single node failing to nudge shouldn't abort."""
for _ in range(rounds):
for port in ports:
try:
nudge_nodeinfo_port(port)
except Exception: # noqa: BLE001 — warmup is best-effort
pass
time.sleep(0.5)
time.sleep(settle)
def _wait_for_pubkey(
tx_iface: Any, rx_num: int, rx_port: str, deadline_s: float = 90.0
) -> bool:
"""Block until `tx_iface` holds `rx_num`'s public key (directed PKI sends
NAK without it). Re-nudges both sides periodically; multi-hop warmup is
slower than the 2-device case because NodeInfo must be relayed, hence the
longer default deadline."""
deadline = time.monotonic() + deadline_s
last_nudge = time.monotonic()
while time.monotonic() < deadline:
rec = (tx_iface.nodesByNum or {}).get(rx_num, {})
if rec.get("user", {}).get("publicKey"):
return True
if time.monotonic() - last_nudge > 20.0:
nudge_nodeinfo_port(rx_port)
nudge_nodeinfo(tx_iface)
last_nudge = time.monotonic()
time.sleep(1.0)
return False
def _traceroute_route(tx_port: str, rx_num: int, rx_port: str) -> list[int] | None:
"""Run a traceroute TX→RX and return the forward `route` (list of relay node
numbers), or None if it couldn't be obtained. Mirrors test_traceroute's
request/PKI/retry pattern."""
from meshtastic.mesh_interface import MeshInterface
with ReceiveCollector(tx_port, topic="meshtastic.receive.traceroute") as tx:
nudge_nodeinfo_port(rx_port)
tx.broadcast_nodeinfo_ping()
if not _wait_for_pubkey(tx._iface, rx_num, rx_port, 60.0):
return None
for _attempt in range(2):
try:
tx._iface.sendTraceRoute(dest=rx_num, hopLimit=5)
break
except MeshInterface.MeshInterfaceError:
time.sleep(5.0)
else:
return None
pkt = tx.wait_for(lambda p: p.get("from") == rx_num, timeout=8.0)
if pkt is None:
return None
tr = (pkt.get("decoded", {}) or {}).get("traceroute") or {}
return [int(n) for n in (tr.get("route") or [])]
@pytest.fixture(scope="session")
def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
"""Discover a real multi-hop pier (tx → relay → rx) on the bench, or skip.
Returns {tx_role, tx_port, rx_role, rx_port, rx_num, relay_role, relay_num}.
"""
roles = sorted(baked_mesh)
if len(roles) < 3:
pytest.skip(
"multi-hop NextHop test needs ≥3 baked devices arranged as a line "
"(endpoints out of direct RF range). Add a third role via "
f"--hub-profile. Detected roles: {roles}"
)
by_role = {r: (baked_mesh[r]["port"], baked_mesh[r]["my_node_num"]) for r in roles}
if any(num is None for _, num in by_role.values()):
pytest.skip("a baked device is missing my_node_num; can't map the topology")
_warm_mesh([port for port, _ in by_role.values()])
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
multihop_pair: tuple[str, str] | None = None
for a_role in roles:
a_port, _ = by_role[a_role]
try:
with connect(port=a_port) as a_iface:
nodes = a_iface.nodesByNum or {}
except Exception: # noqa: BLE001
continue
for c_role in roles:
if c_role == a_role:
continue
_, c_num = by_role[c_role]
hops = _hops_away(nodes.get(c_num, {}))
if hops is not None and hops >= 1:
multihop_pair = (a_role, c_role)
break
if multihop_pair:
break
if not multihop_pair:
pytest.skip(
"no multi-hop pair found — every device appears to be a direct "
"neighbor. Arrange the bench as a line (A — B — C) with the "
"endpoints out of direct RF range (distance or attenuators) so a "
"relay is actually required, then re-run."
)
a_role, c_role = multihop_pair
a_port, _ = by_role[a_role]
c_port, c_num = by_role[c_role]
route = _traceroute_route(a_port, c_num, c_port)
if not route:
pytest.skip(
f"{a_role}{c_role} looked multi-hop but traceroute returned no "
"intermediate relay; can't identify the relay node to drive the "
"recovery test"
)
relay_num = route[0]
relay_role = next((r for r in roles if by_role[r][1] == relay_num), None)
return {
"tx_role": a_role,
"tx_port": a_port,
"rx_role": c_role,
"rx_port": c_port,
"rx_num": c_num,
"relay_role": relay_role,
"relay_num": relay_num,
}
@pytest.mark.timeout(300)
def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
"""A directed wantAck DM that must traverse the relay is delivered.
Exercises the NextHop routing path end-to-end: TX picks a next hop toward
RX (M2 gate), the relay resolves the next_hop byte and forwards (M1), and
the route is learned from the returning ACK (M3). Retries absorb transient
LoRa loss; the assertion is on eventual delivery.
"""
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
relay_role = multihop_topology["relay_role"]
unique = f"nexthop-mh-{tx_role}-to-{rx_role}-{int(time.time())}"
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip(
f"{tx_role} never learned {rx_role}'s pubkey over the relay; "
"multi-hop PKI warmup didn't complete"
)
got = None
for _attempt in range(3):
pkt = tx_iface.sendText(unique, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == unique,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(5.0)
assert got is not None, (
f"multi-hop directed DM {tx_role}{rx_role} via relay "
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
)
@pytest.mark.timeout(600)
def test_multihop_relay_recovery(
multihop_topology: dict[str, Any],
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
) -> None:
"""Delivery recovers after the established relay drops and returns.
Establishes a baseline DM (route via relay learned), powers the relay OFF
(confirming TX survives sending across a downed relay), then powers it back
ON and asserts directed delivery resumes — the M3 stale-route decay /
re-learn path. With a strict A — B — C line there is no path while B is down,
so we only assert TX doesn't crash during the outage; the delivery assertion
is after B returns.
"""
relay_role = multihop_topology["relay_role"]
if not relay_role:
pytest.skip(
"relay node isn't one of the baked hub roles, so it can't be "
"power-cycled; recovery test needs a controllable relay"
)
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
base = f"mh-recover-base-{int(time.time())}"
post = f"mh-recover-post-{int(time.time())}"
# Baseline: confirm delivery works (so the route via the relay is learned)
# before we perturb anything — otherwise a later failure is ambiguous.
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip("multi-hop PKI warmup failed; can't run recovery test")
tx_iface.sendText(base, destinationId=rx_num, wantAck=True)
assert (
rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
)
is not None
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
# Power the relay OFF.
try:
_power.power_off(relay_role)
_power.wait_for_absence(relay_role, timeout_s=15.0)
except Exception as exc: # noqa: BLE001
try:
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001
pass
pytest.skip(f"can't power-control relay {relay_role!r}: {exc}")
# With the only relay down there's no path; we just confirm TX accepts the
# send and survives its internal retries (it must not crash / wedge).
try:
with connect(port=tx_port) as tx_iface:
pkt = tx_iface.sendText(
f"mh-while-down-{int(time.time())}",
destinationId=rx_num,
wantAck=True,
)
assert pkt is not None
time.sleep(8.0) # let retransmissions + route decay run
except Exception as exc: # noqa: BLE001 — restore bench state before failing
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
raise AssertionError(
f"TX crashed sending across a downed relay: {exc}"
) from exc
# Power the relay back ON and let it re-enumerate + boot.
_power.power_on(relay_role)
time.sleep(0.5)
try:
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
pass
time.sleep(8.0)
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
# Delivery should resume once the relay is back (M3 re-learn / decay path).
got = None
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
_wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0)
for _attempt in range(4):
pkt = tx_iface.sendText(post, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == post,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(6.0)
assert got is not None, (
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}{rx_role} "
"never resumed — stale-route recovery (M3) may be broken"
)
+1 -1
View File
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
"esp32-c6",
"esp32c6",
}
_NRF52_ARCHES = {"nrf52", "nrf52840"}
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
+32 -12
View File
@@ -8,10 +8,30 @@ extra_configs =
variants/*/*.ini
variants/*/*/platformio.ini
variants/*/diy/*/platformio.ini
src/graphics/niche/InkHUD/PlatformioConfig.ini
description = Meshtastic
; E-Ink / NicheGraphics build helpers.
[niche]
build_src_filter =
+<graphics/eink/>
build_flags =
-D MESHTASTIC_INCLUDE_NICHE_GRAPHICS
[inkhud]
build_src_filter =
${niche.build_src_filter}
+<graphics/niche/>
build_flags =
${niche.build_flags}
-D MESHTASTIC_INCLUDE_INKHUD ; Use InkHUD as the UI
-D MESHTASTIC_EXCLUDE_SCREEN ; Suppress default Screen class
-D MESHTASTIC_EXCLUDE_INPUTBROKER ; Suppress default input handling
-D HAS_BUTTON=0 ; Suppress default ButtonThread
lib_deps =
# renovate: datasource=github-tags depName=GFX_Root packageName=ZinggJM/GFX_Root
https://github.com/ZinggJM/GFX_Root/archive/3195764e352a0d2567c8d277ac408ca7293a99b0.zip ; Used by InkHUD as a "slimmer" version of AdafruitGFX
[env]
test_build_src = true
extra_scripts =
@@ -103,7 +123,7 @@ build_unflags =
-std=gnu++11
build_flags = ${env.build_flags} -Os
-std=gnu++17
build_src_filter = ${env.build_src_filter} -<platform/> +<platform/extra_variants/> -<graphics/niche/>
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/> -<graphics/eink/>
; Common libs for communicating over TCP/IP networks such as MQTT
[networking_base]
@@ -200,16 +220,6 @@ lib_deps =
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
https://github.com/Sensirion/arduino-i2c-scd30/archive/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
; Common environmental sensor libraries (not included in native / portduino)
[environmental_extra_common]
@@ -228,6 +238,16 @@ lib_deps =
closedcube/ClosedCube OPT3001@1.1.2
# renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master
https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
[environmental_extra]
+2 -2
View File
@@ -14,8 +14,8 @@
#define FILE_O_READ "r"
#endif
#if defined(ARCH_STM32)
// STM32
#if defined(ARCH_STM32WL)
// STM32WL
#include "LittleFS.h"
#define FSCom InternalFS
#define FSBegin() FSCom.begin()
-1
View File
@@ -354,7 +354,6 @@ void MessageStore::clearAllMessages()
resetMessagePool();
#ifdef FSCom
concurrency::LockGuard guard(spiLock);
SafeFile f(filename.c_str(), false);
uint8_t count = 0;
f.write(&count, 1); // write "0 messages"
+6 -11
View File
@@ -14,7 +14,6 @@
* For more information, see: https://meshtastic.org/
*/
#include "power.h"
#include "BluetoothCommon.h"
#include "MessageStore.h"
#include "NodeDB.h"
#include "PowerFSM.h"
@@ -48,7 +47,7 @@
#include "concurrency/LockGuard.h"
#endif
#if defined(ARCH_STM32) && defined(BATTERY_PIN)
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
#include "stm32yyxx_ll_adc.h"
/* Analog read resolution */
@@ -431,7 +430,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
float scaled = 0;
battery_adcEnable();
#ifdef ARCH_STM32
#ifdef ARCH_STM32WL
// STM32 ADC with VREFINT runtime calibration
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
raw = analogRead(BATTERY_PIN);
@@ -608,7 +607,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
bool initial_read_done = false;
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
uint32_t last_read_time_ms = 0;
#ifdef ARCH_STM32
#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;
@@ -718,7 +717,7 @@ bool Power::analogInit()
#define BATTERY_SENSE_RESOLUTION_BITS 10
#endif
#ifdef ARCH_STM32
#ifdef ARCH_STM32WL
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
adc_oneshot_unit_init_cfg_t init_config = {
@@ -749,7 +748,7 @@ bool Power::analogInit()
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32)
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#endif
@@ -838,7 +837,7 @@ void Power::reboot()
}
LOG_DEBUG("final reboot!");
::reboot();
#elif defined(ARCH_STM32)
#elif defined(ARCH_STM32WL)
HAL_NVIC_SystemReset();
#else
rebootAtMsec = -1;
@@ -963,10 +962,6 @@ void Power::readPowerStatus()
lastLogTime = millis();
}
newStatus.notifyObservers(&powerStatus2);
// Mirror battery level to the BLE Battery Service (0x2A19); the platform layer clamps and dedupes.
if (hasBattery == OptTrue)
updateBatteryLevel(powerStatus2.getBatteryChargePercent());
#ifdef DEBUG_HEAP
if (lastheap != memGet.getFreeHeap()) {
// Use stack-allocated buffer to avoid heap allocations in monitoring code
-4
View File
@@ -219,11 +219,7 @@ static void darkEnter()
static void serialEnter()
{
LOG_POWERFSM("State: serialEnter");
#ifndef ARCH_NRF52
// nRF52 runs BLE on SoftDevice independently of USB serial — no need to disable it.
// (Same rationale as nbEnter() which already guards this with #ifdef ARCH_ESP32)
setBluetoothEnable(false);
#endif
if (screen) {
screen->setOn(true);
}
-21
View File
@@ -7,7 +7,6 @@
#include "memGet.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <assert.h>
#include <atomic>
#include <cstring>
#include <memory>
#include <stdexcept>
@@ -21,22 +20,6 @@
#if HAS_NETWORKING
extern meshtastic::Syslog syslog;
#endif
namespace
{
std::atomic<bool> serialHalLogSuppressed{false};
}
void RedirectablePrint::setSerialHalLogSuppressed(bool suppressed)
{
serialHalLogSuppressed.store(suppressed);
}
bool RedirectablePrint::isSerialHalLogSuppressed()
{
return serialHalLogSuppressed.load();
}
void RedirectablePrint::rpInit()
{
#ifdef HAS_FREE_RTOS
@@ -298,10 +281,6 @@ meshtastic_LogRecord_Level RedirectablePrint::getLogLevel(const char *logLevel)
void RedirectablePrint::log(const char *logLevel, const char *format, ...)
{
if (isSerialHalLogSuppressed()) {
return;
}
// append \n to format
size_t len = strlen(format);
auto newFormat = std::unique_ptr<char[]>(new char[len + 2]);
-5
View File
@@ -24,11 +24,6 @@ class RedirectablePrint : public Print
public:
explicit RedirectablePrint(Print *_dest) : dest(_dest) {}
/// Suppress all log output while a SerialHal transaction is in progress.
// Unclear if this is necessary, but it seems to help with response speeds.
static void setSerialHalLogSuppressed(bool suppressed);
static bool isSerialHalLogSuppressed();
/**
* Set a new destination
*/
-96
View File
@@ -179,13 +179,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#endif
#endif
#ifdef USE_KCT8103L_PA_ONLY
#if defined(HELTEC_MESH_TOWER_V2)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 8, 7
#endif
#endif
#ifdef RAK13302
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 7, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8
@@ -580,94 +573,5 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define USE_ETHERNET_DEFAULT 0
#endif
// -----------------------------------------------------------------------------
// MESHTASTIC_LOCKDOWN — runtime, client-toggleable hardening (nRF52 only)
//
// Lockdown/protect support is opt-in at build time. Builds that need it pass
// -DMESHTASTIC_ENABLE_LOCKDOWN=1. When enabled on nRF52 (CC310 hardware
// crypto), whether it is ACTIVE is decided entirely at runtime by
// EncryptedStorage::isLockdownActive()
// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
// that has never been provisioned — or that the operator disabled from the
// client app — behaves exactly like stock firmware: plaintext storage, no
// redaction, normal logging, normal display.
//
// The operator toggles lockdown from the client app:
// off -> on : provision a passphrase (AdminMessage.lockdown_auth). The
// firmware generates a DEK, encrypts the stored config, and
// authorizes the connection.
// on -> off : AdminMessage.lockdown_auth { disable=true } with the
// passphrase — decrypts storage back to plaintext and removes
// the DEK / token / monotonic-counter / backoff files, then
// reboots into normal mode. APPROTECT is the one thing that
// does NOT revert (see below).
//
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker. It gates the UI
// bits (lock screen, pairing-PIN handling). Flash-constrained nRF52 variants
// that genuinely cannot afford the ~tens-of-KB of crypto + access-control code
// may also opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
//
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — per-connection auth + redaction,
// gated at runtime on isLockdownActive()
// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest
// MESHTASTIC_ENABLE_APPROTECT — UICR APPROTECT capability. The actual
// one-way burn happens at runtime, only
// once provisioned, only on non-vulnerable
// silicon, and is STICKY: disabling
// lockdown does NOT (cannot) reverse it.
//
// DEBUG_MUTE is intentionally NOT coupled to lockdown — a capable-but-off
// device must log normally. Define DEBUG_MUTE separately for a silent build.
//
// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
// even when provisioned — for development so dev boards never lose SWD.
// -----------------------------------------------------------------------------
#if defined(ARCH_NRF52)
#ifndef MESHTASTIC_ENABLE_LOCKDOWN
#define MESHTASTIC_ENABLE_LOCKDOWN 0
#endif
#if !MESHTASTIC_ENABLE_LOCKDOWN
#undef MESHTASTIC_LOCKDOWN
#undef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
#undef MESHTASTIC_ENCRYPTED_STORAGE
#undef MESHTASTIC_ENABLE_APPROTECT
#ifndef MESHTASTIC_EXCLUDE_LOCKDOWN
#define MESHTASTIC_EXCLUDE_LOCKDOWN 1
#endif
#endif
#if MESHTASTIC_ENABLE_LOCKDOWN && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
#define MESHTASTIC_LOCKDOWN 1
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
#define MESHTASTIC_ENCRYPTED_STORAGE 1
#ifndef MESHTASTIC_LOCKDOWN_DEBUG
#define MESHTASTIC_ENABLE_APPROTECT 1
#endif
#endif
#endif
#ifdef MESHTASTIC_LOCKDOWN
// Per-boot uptime cap on unlocked sessions. 0 = unlimited (token-only
// enforcement, the existing behavior). When non-zero, every passphrase
// unlock (and every token-auto-unlock that inherits the value) arms a
// timer; on expiry the device lockNow()s and reboots into locked state.
// Bounds the total exposure window to bootsRemaining * this value if an
// attacker has physical possession but not the passphrase.
//
// Override at build time. Suggested:
// carry device: 3600 (1h sessions, periodic re-auth from phone)
// tower / infra node: 0 (default — relies on token TTLs only)
//
// A future LockdownAuth.max_session_seconds proto field will let the
// client set this per-token; until that lands the build-time value is
// the only source.
#ifndef MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS
#define MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS 0
#endif
#endif // MESHTASTIC_LOCKDOWN
#include "DebugConfiguration.h"
#include "RF95Configuration.h"
+5 -5
View File
@@ -37,15 +37,15 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
return firstOfOrNONE(12, types);
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
return firstOfOrNONE(11, types);
}
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
{
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
return firstOfOrNONE(2, types);
ScanI2C::DeviceType types[] = {MMC5983MA};
return firstOfOrNONE(1, types);
}
ScanI2C::FoundDevice ScanI2C::firstAQI() const
-2
View File
@@ -99,8 +99,6 @@ class ScanI2C
CW2015,
SCD30,
ADS1115,
IIS2MDCTR,
ISM330DHCX,
} DeviceType;
// typedef uint8_t DeviceAddress;
+2 -15
View File
@@ -8,7 +8,7 @@
#if defined(ARCH_PORTDUINO)
#include "linux/LinuxHardwareI2C.h"
#endif
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
#include "meshUtils.h" // vformat
#endif
@@ -584,9 +584,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
if (registerValue == 0x6A) {
type = LSM6DS3;
logFoundDevice("LSM6DS3", (uint8_t)addr.address);
} else if (registerValue == 0x6B) {
type = ISM330DHCX;
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
} else {
type = QMI8658;
logFoundDevice("QMI8658", (uint8_t)addr.address);
@@ -594,17 +591,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
SCAN_SIMPLE_CASE(QMC5883L_ADDR, QMC5883L, "QMC5883L", (uint8_t)addr.address)
case HMC5883L_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x4FU), 1); // get ID
if (registerValue == 0x40) {
type = IIS2MDCTR;
logFoundDevice("IIS2MDCTR", (uint8_t)addr.address);
break;
} else {
type = HMC5883L;
logFoundDevice("HMC5883L", (uint8_t)addr.address);
break;
}
SCAN_SIMPLE_CASE(HMC5883L_ADDR, HMC5883L, "HMC5883L", (uint8_t)addr.address)
#ifdef HAS_QMA6100P
SCAN_SIMPLE_CASE(QMA6100P_ADDR, QMA6100P, "QMA6100P", (uint8_t)addr.address)
#else
+18 -361
View File
@@ -17,10 +17,7 @@
#include "main.h" // pmu_found
#include "sleep.h"
#include "FSCommon.h"
#include "GPSUpdateScheduling.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "cas.h"
#include "ubx.h"
@@ -47,7 +44,7 @@ template <typename T, std::size_t N> std::size_t array_count(const T (&)[N])
#if defined(ARCH_NRF52)
Uart *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
HardwareSerial *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_RP2040)
SerialUART *GPS::_serial_gps = &GPS_SERIAL_PORT;
@@ -74,112 +71,6 @@ static struct uBloxGnssModelInfo {
#define GPS_SOL_EXPIRY_MS 5000 // in millis. give 1 second time to combine different sentences. NMEA Frequency isn't higher anyway
#define NMEA_MSG_GXGSA "GNGSA" // GSA message (GPGSA, GNGSA etc)
namespace
{
// Versioned on-disk record for persisted GPS probe results.
constexpr uint32_t GPS_PROBE_CACHE_MAGIC = 0x47504348UL; // "GPCH"
constexpr uint16_t GPS_PROBE_CACHE_VERSION = 1;
constexpr const char *GPS_PROBE_CACHE_FILE = "/prefs/gps_probe_cache.dat";
constexpr int MIN_PLAUSIBLE_GPS_YEAR = 2020;
constexpr int MAX_PLAUSIBLE_GPS_YEAR = 2100;
#ifdef TRACKER_T1000_E
constexpr uint32_t T1000_E_AIROHA_WAKE_MS = 1000;
constexpr uint32_t T1000_E_AIROHA_WAKE_INTERVAL_MS = 40;
#endif
struct GPSProbeCacheRecord {
uint32_t magic;
uint16_t version;
uint16_t reserved;
uint32_t baud;
uint8_t model;
};
bool isValidGnssModel(uint8_t model)
{
// Keep persisted values bounded to known enum range.
return model <= static_cast<uint8_t>(GNSS_MODEL_CM121);
}
bool isValidProbeBaud(uint32_t baud)
{
// Conservative sanity range for UART baud values.
return baud >= 1200 && baud <= 921600;
}
template <typename T> void wakeAirohaForActiveProbe(T *serialGps)
{
#ifdef TRACKER_T1000_E
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
digitalWrite(GPS_RTC_INT, HIGH);
delay(3);
digitalWrite(GPS_RTC_INT, LOW);
delay(50);
const uint32_t start = millis();
do {
serialGps->write("$PAIR382,1*2E\r\n");
delay(T1000_E_AIROHA_WAKE_INTERVAL_MS);
} while (Throttle::isWithinTimespanMs(start, T1000_E_AIROHA_WAKE_MS));
#elif defined(GNSS_AIROHA)
serialGps->write("$PAIR382,1*2E\r\n");
delay(20);
#else
(void)serialGps;
#endif
}
bool isPlausibleNmeaTime(const struct tm &t)
{
const int year = t.tm_year + 1900;
if (year < MIN_PLAUSIBLE_GPS_YEAR || year > MAX_PLAUSIBLE_GPS_YEAR) {
return false;
}
#ifdef BUILD_EPOCH
const int64_t candidate = static_cast<int64_t>(gm_mktime(&t));
const int64_t minEpoch = static_cast<int64_t>(BUILD_EPOCH);
const int64_t maxEpoch = minEpoch + static_cast<int64_t>(FORTY_YEARS);
return candidate >= minEpoch && candidate <= maxEpoch;
#else
return true;
#endif
}
template <typename T> bool sawNmeaSentenceAtBaud(T *serialGps, uint32_t timeoutMs)
{
// Lightweight passive check: look for at least one complete
// "$...,<field>\n" style NMEA sentence.
const uint32_t deadline = millis() + timeoutMs;
bool sawDollar = false;
bool sawComma = false;
while ((int32_t)(millis() - deadline) < 0) {
while (serialGps->available()) {
char c = static_cast<char>(serialGps->read());
if (c == '$') {
sawDollar = true;
sawComma = false;
continue;
}
if (c == ',') {
sawComma = true;
}
if (c == '\n' || c == '\r') {
if (sawDollar && sawComma) {
return true;
}
sawDollar = false;
sawComma = false;
}
}
delay(10);
}
return false;
}
} // namespace
// For logging
static const char *getGPSPowerStateString(GPSPowerState state)
{
@@ -601,201 +492,6 @@ static const int rareSerialSpeeds[3] = {4800, 57600, GPS_BAUDRATE};
#define GPS_PROBETRIES 2
#endif
bool GPS::loadProbeCache()
{
#ifdef FSCom
// Load the last known-good GPS model/baud pair so we can avoid a full probe
// sweep on every boot.
triedProbeCache = true; // Latch this boot's load attempt, even if no cache.
GPSProbeCacheRecord record = {};
size_t bytesRead = 0;
spiLock->lock();
auto file = FSCom.open(GPS_PROBE_CACHE_FILE, FILE_O_READ);
if (!file) {
spiLock->unlock();
return false;
}
bytesRead = file.read(reinterpret_cast<uint8_t *>(&record), sizeof(record));
file.close();
spiLock->unlock();
const bool headerValid = (bytesRead == sizeof(record)) && (record.magic == GPS_PROBE_CACHE_MAGIC) &&
(record.version == GPS_PROBE_CACHE_VERSION) && (record.reserved == 0U);
if (!headerValid || !isValidGnssModel(record.model) || !isValidProbeBaud(record.baud)) {
clearProbeCache(); // Drop corrupt/invalid cache so next boot can
// recover.
return false;
}
cachedProbeBaud = static_cast<int32_t>(record.baud);
cachedProbeModel = static_cast<GnssModel_t>(record.model);
hasProbeCache = true;
triedProbeCache = false;
LOG_INFO("Loaded cached GPS probe: baud=%u", record.baud);
return true;
#else
return false;
#endif
}
void GPS::clearProbeCache()
{
// Invalidate in-memory and on-disk cache so next boot is forced to do a
// full probe.
hasProbeCache = false;
triedProbeCache = true;
cachedProbeBaud = 0;
cachedProbeModel = GNSS_MODEL_UNKNOWN;
#ifdef FSCom
spiLock->lock();
if (FSCom.exists(GPS_PROBE_CACHE_FILE)) {
FSCom.remove(GPS_PROBE_CACHE_FILE);
}
spiLock->unlock();
#endif
}
bool GPS::saveProbeCache() const
{
#ifdef FSCom
if (gnssModel == GNSS_MODEL_UNKNOWN || !isValidGnssModel(static_cast<uint8_t>(gnssModel)) ||
!isValidProbeBaud(detectedBaud)) {
return false;
}
spiLock->lock();
FSCom.mkdir("/prefs");
spiLock->unlock();
GPSProbeCacheRecord record = {
GPS_PROBE_CACHE_MAGIC, GPS_PROBE_CACHE_VERSION, 0, static_cast<uint32_t>(detectedBaud), static_cast<uint8_t>(gnssModel),
};
auto file = SafeFile(GPS_PROBE_CACHE_FILE, true);
spiLock->lock();
const size_t written = file.write(reinterpret_cast<const uint8_t *>(&record), sizeof(record));
spiLock->unlock();
return (written == sizeof(record)) && file.close();
#else
return false;
#endif
}
bool GPS::verifyCachedProbePresence()
{
if (!hasProbeCache || cachedProbeModel == GNSS_MODEL_UNKNOWN || !isValidProbeBaud(cachedProbeBaud)) {
return false;
}
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
_serial_gps->end();
_serial_gps->begin(cachedProbeBaud);
#elif defined(ARCH_RP2040)
_serial_gps->end();
_serial_gps->setFIFOSize(256);
_serial_gps->begin(cachedProbeBaud);
#else
if (_serial_gps->baudRate() != cachedProbeBaud) {
LOG_DEBUG("Set GPS Baud to %i (cached verify)", cachedProbeBaud);
_serial_gps->updateBaudRate(cachedProbeBaud);
}
#endif
// Before trusting cached model/baud, require either active model-specific
// response or passive NMEA flow.
clearBuffer();
bool present = false;
// Model-specific "active ping" checks to avoid false stale decisions on
// modules that start streaming late.
const char *cachedProbeModelName = "UNKNOWN";
switch (cachedProbeModel) {
case GNSS_MODEL_MTK:
cachedProbeModelName = "L76K/MTK";
_serial_gps->write("$PCAS06,0*1B\r\n");
present = (getACK("$GPTXT,01,01,02,SW=", 700) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_MTK_L76B:
cachedProbeModelName = "L76B";
case GNSS_MODEL_MTK_PA1010D:
if (cachedProbeModel == GNSS_MODEL_MTK_PA1010D)
cachedProbeModelName = "PA1010D";
case GNSS_MODEL_MTK_PA1616S:
if (cachedProbeModel == GNSS_MODEL_MTK_PA1616S)
cachedProbeModelName = "PA1616S";
case GNSS_MODEL_LS20031:
if (cachedProbeModel == GNSS_MODEL_LS20031)
cachedProbeModelName = "LS20031";
_serial_gps->write("$PMTK605*31\r\n");
present = (getACK("$PMTK705", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_AG3335:
cachedProbeModelName = "AG3335";
case GNSS_MODEL_AG3352:
if (cachedProbeModel == GNSS_MODEL_AG3352)
cachedProbeModelName = "AG3352";
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR021*39\r\n");
present = (getACK("$PAIR021,", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_ATGM336H:
cachedProbeModelName = "ATGM336H";
_serial_gps->write("$PCAS06,1*1A\r\n");
present = (getACK("$GPTXT,01,01,02,HW=ATGM", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_UC6580:
cachedProbeModelName = "UC6580/UM600";
_serial_gps->write("$PDTINFO\r\n");
present = (getACK("UC6580", 900) == GNSS_RESPONSE_OK) || (getACK("UM600", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_CM121:
cachedProbeModelName = "CM121";
_serial_gps->write("$PDTINFO\r\n");
present = (getACK("CM121", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_UBLOX6:
case GNSS_MODEL_UBLOX7:
case GNSS_MODEL_UBLOX8:
case GNSS_MODEL_UBLOX9:
case GNSS_MODEL_UBLOX10: {
if (cachedProbeModel == GNSS_MODEL_UBLOX6)
cachedProbeModelName = "U-blox 6";
else if (cachedProbeModel == GNSS_MODEL_UBLOX7)
cachedProbeModelName = "U-blox 7";
else if (cachedProbeModel == GNSS_MODEL_UBLOX8)
cachedProbeModelName = "U-blox 8";
else if (cachedProbeModel == GNSS_MODEL_UBLOX9)
cachedProbeModelName = "U-blox 9";
else if (cachedProbeModel == GNSS_MODEL_UBLOX10)
cachedProbeModelName = "U-blox 10";
uint8_t cfg_rate[] = {0xB5, 0x62, 0x06, 0x08, 0x00, 0x00, 0x00, 0x00};
UBXChecksum(cfg_rate, sizeof(cfg_rate));
_serial_gps->write(cfg_rate, sizeof(cfg_rate));
present = (getACK(0x06, 0x08, 900) != GNSS_RESPONSE_NONE);
break;
}
default:
break;
}
if (!present) {
// Some modules may not respond to probes while still streaming NMEA, so
// allow a passive fallback check.
present = sawNmeaSentenceAtBaud(_serial_gps, 3000);
}
if (!present) {
LOG_WARN("Cached GPS probe is stale (%s @ %d), clearing cache", cachedProbeModelName, cachedProbeBaud);
clearProbeCache();
return false;
}
detectedBaud = cachedProbeBaud;
gnssModel = cachedProbeModel;
LOG_INFO("Using cached GPS probe: %s @ %d", cachedProbeModelName, detectedBaud);
return true;
}
/**
* @brief Setup the GPS based on the model detected.
* We detect the GPS by cycling through a set of baud rates, first common then rare.
@@ -808,39 +504,24 @@ bool GPS::setup()
if (!didSerialInit) {
int msglen = 0;
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
if (!hasProbeCache && !triedProbeCache) {
(void)loadProbeCache();
}
if (hasProbeCache && !triedProbeCache) {
triedProbeCache = true;
if (!verifyCachedProbePresence()) {
currentStep = 0;
speedSelect = 0;
probeTries = 0;
}
}
if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries < GPS_PROBETRIES) {
// No usable cache: walk common baud rates first.
if (probeTries < GPS_PROBETRIES) {
gnssModel = probe(serialSpeeds[speedSelect]);
if (gnssModel != GNSS_MODEL_UNKNOWN) {
detectedBaud = serialSpeeds[speedSelect];
} else if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
speedSelect = 0;
++probeTries;
if (gnssModel == GNSS_MODEL_UNKNOWN) {
if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
speedSelect = 0;
++probeTries;
}
}
}
// Rare Serial Speeds
#ifndef CONFIG_IDF_TARGET_ESP32C6
else if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries == GPS_PROBETRIES) {
// Then try less common baud rates before giving up.
if (probeTries == GPS_PROBETRIES) {
gnssModel = probe(rareSerialSpeeds[speedSelect]);
if (gnssModel != GNSS_MODEL_UNKNOWN) {
detectedBaud = rareSerialSpeeds[speedSelect];
} else if (currentStep == 0 && ++speedSelect == array_count(rareSerialSpeeds)) {
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
return true;
if (gnssModel == GNSS_MODEL_UNKNOWN) {
if (currentStep == 0 && ++speedSelect == array_count(rareSerialSpeeds)) {
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
return true;
}
}
}
#endif
@@ -848,7 +529,6 @@ bool GPS::setup()
if (gnssModel != GNSS_MODEL_UNKNOWN) {
setConnected();
(void)saveProbeCache();
} else {
return false;
}
@@ -1160,22 +840,10 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
switch (newState) {
case GPS_ACTIVE:
case GPS_IDLE:
if (oldState == GPS_ACTIVE)
break;
gotTime = false;
if (oldState == GPS_IDLE) // If hardware already awake, no changes needed
if (oldState == GPS_ACTIVE || oldState == GPS_IDLE) // If hardware already awake, no changes needed
break;
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
clearBuffer();
#ifdef TRACKER_T1000_E
pinMode(GPS_VRTC_EN, OUTPUT);
digitalWrite(GPS_VRTC_EN, HIGH);
pinMode(GPS_SLEEP_INT, OUTPUT);
digitalWrite(GPS_SLEEP_INT, HIGH);
pinMode(GPS_RTC_INT, OUTPUT);
digitalWrite(GPS_RTC_INT, LOW);
pinMode(GPS_RESETB_OUT, INPUT_PULLUP);
#endif
powerMon->setState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinEN(true); // Power (EN pin): on
setPowerPMU(true); // Power (PMU): on
@@ -1434,11 +1102,6 @@ int32_t GPS::runOnce()
if (!setup())
return currentDelay; // Setup failed, re-run in two seconds
if (gnssModel == GNSS_MODEL_UNKNOWN) {
LOG_WARN("GPS not detected; marked not present for this boot");
return disable();
}
// We have now loaded our saved preferences from flash
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
return disable();
@@ -1487,7 +1150,8 @@ int32_t GPS::runOnce()
// if gps_update_interval is <=10s, GPS never goes off, so we treat that differently
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
// 1. Got a time for the first time this cycle
// 1. Got a time for the first time
bool gotTime = (getRTCQuality() >= RTCQualityGPS);
if (!gotTime && lookForTime()) { // Note: we count on this && short-circuiting and not resetting the RTC time
gotTime = true;
}
@@ -1613,7 +1277,7 @@ GnssModel_t GPS::probe(int serialSpeed)
switch (currentStep) {
case 0: {
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
_serial_gps->end();
_serial_gps->begin(serialSpeed);
#elif defined(ARCH_RP2040)
@@ -1634,9 +1298,6 @@ GnssModel_t GPS::probe(int serialSpeed)
digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); // assert for 10ms
delay(10);
digitalWrite(PIN_GPS_RESET, !GPS_RESET_MODE);
#ifdef TRACKER_T1000_E
delay(100);
#endif
// attempt to detect the chip based on boot messages
std::vector<ChipInfo> passive_detect = {
@@ -1689,7 +1350,6 @@ GnssModel_t GPS::probe(int serialSpeed)
}
case 3: {
/* Airoha (Mediatek) AG3335A/M/S, A3352Q, Quectel L89 2.0, SimCom SIM65M */
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF to reduce volume
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF to reduce volume
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
@@ -1974,7 +1634,7 @@ std::unique_ptr<GPS> GPS::createGps()
#elif defined(ARCH_NRF52)
_serial_gps->setPins(new_gps->rx_gpio, new_gps->tx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
#elif defined(ARCH_STM32)
#elif defined(ARCH_STM32WL)
_serial_gps->setTx(new_gps->tx_gpio);
_serial_gps->setRx(new_gps->rx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
@@ -2021,9 +1681,6 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s
t.tm_year = d.year() - 1900;
t.tm_isdst = false;
if (t.tm_mon > -1) {
if (!isPlausibleNmeaTime(t)) {
return false;
}
if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultSuccess) {
LOG_DEBUG("NMEA GPS time set %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour,
t.tm_min, t.tm_sec, ti.age());
-16
View File
@@ -155,26 +155,14 @@ class GPS : private concurrency::OSThread
* @return true if we've acquired a new location
*/
virtual bool lookForLocation();
// Load persisted GPS model+baud from /prefs.
bool loadProbeCache();
// Clear persisted GPS model+baud cache.
void clearProbeCache();
// Persist the currently detected GPS model+baud.
bool saveProbeCache() const;
// Verify the cached model+baud still maps to a live GPS device.
bool verifyCachedProbePresence();
GnssModel_t gnssModel = GNSS_MODEL_UNKNOWN;
int32_t detectedBaud = GPS_BAUDRATE;
int32_t cachedProbeBaud = 0;
GnssModel_t cachedProbeModel = GNSS_MODEL_UNKNOWN;
TinyGPSPlus reader;
uint8_t fixQual = 0; // fix quality from GPGGA
uint32_t lastChecksumFailCount = 0;
uint8_t currentStep = 0;
int32_t currentDelay = 2000;
bool gotTime = false;
#ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS
// (20210908) TinyGps++ can only read the GPGSA "FIX TYPE" field
@@ -190,10 +178,6 @@ class GPS : private concurrency::OSThread
uint8_t speedSelect = 0;
uint8_t probeTries = 0;
// Cache file is successfully loaded.
bool hasProbeCache = false;
// Ensures cached probe is attempted once per boot.
bool triedProbeCache = false;
/**
* hasValidLocation - indicates that the position variables contain a complete
+2 -2
View File
@@ -219,8 +219,8 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
} else if (q == RTCQualityGPS) {
shouldSet = true;
LOG_DEBUG("Reapply GPS time: %ld secs", printableEpoch);
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (30 * 60 * 1000UL))) {
// Every 30 minutes we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (12 * 60 * 60 * 1000UL))) {
// Every 12 hrs we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
shouldSet = true;
LOG_DEBUG("Reapply external time to correct clock drift %ld secs", printableEpoch);
} else {
+211
View File
@@ -0,0 +1,211 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./BaseUIEInkDisplay.h"
#include "configuration.h"
#include "main.h"
using namespace NicheGraphics;
BaseUIEInkDisplay::BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation) : driver(driver), rotation(rotation & 0x3)
{
this->geometry = GEOMETRY_RAWMODE;
// BaseUI draws in UI orientation. Physical panel dimensions are swapped for 90°/270°.
const bool swap = (this->rotation == 1) || (this->rotation == 3);
this->displayWidth = swap ? driver->height : driver->width;
this->displayHeight = swap ? driver->width : driver->height;
uint16_t shortSide = min(displayWidth, displayHeight);
uint16_t longSide = max(displayWidth, displayHeight);
if (shortSide % 8 != 0)
shortSide = (shortSide | 7) + 1;
this->displayBufferSize = longSide * (shortSide / 8);
// Panel-native row-major buffer
panelRowBytes = ((driver->width - 1) / 8) + 1;
panelBufferSize = panelRowBytes * driver->height;
panelBuffer = new uint8_t[panelBufferSize];
memset(panelBuffer, 0xFF, panelBufferSize); // All white
}
BaseUIEInkDisplay::~BaseUIEInkDisplay()
{
delete[] panelBuffer;
}
bool BaseUIEInkDisplay::connect()
{
LOG_INFO("Init BaseUI E-Ink (%u x %u, rot %u)", driver->width, driver->height, rotation);
return true;
}
void BaseUIEInkDisplay::addFrameFlag(frameFlagTypes flag)
{
frameFlags = (frameFlagTypes)(frameFlags | flag);
}
void BaseUIEInkDisplay::setDisplayResilience(uint8_t fastPerFull, float stressMultiplier)
{
this->fastPerFull = (fastPerFull == 0) ? 1 : fastPerFull;
this->stressMultiplier = stressMultiplier;
}
void BaseUIEInkDisplay::joinAsyncRefresh()
{
if (driver->busy())
driver->await();
}
// OLEDDisplayUi tick path. Honours rate-limit unless flags demand otherwise.
void BaseUIEInkDisplay::display()
{
const bool demandFast = frameFlags & DEMAND_FAST;
const bool cosmetic = frameFlags & COSMETIC;
const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
if (!demandFast && !cosmetic && !unlimitedFast) {
if (!forceDisplay(lastDrawMsec == 0 ? 0 : 1000))
return;
return;
}
forceDisplay(0);
}
// Keyframe path. Returns true if a frame was pushed (sets lastDrawMsec).
bool BaseUIEInkDisplay::forceDisplay(uint32_t msecLimit)
{
const uint32_t now = millis();
if (lastDrawMsec != 0 && (now - lastDrawMsec) < msecLimit)
return false;
const bool blocking = frameFlags & BLOCKING;
Drivers::EInk::UpdateTypes type = decide();
// Don't pile frames on top of a running update - wait it out.
if (driver->busy())
driver->await();
const bool pushed = commit(type, blocking);
if (pushed)
lastDrawMsec = now;
// Reset flags for next frame
frameFlags = BACKGROUND;
return pushed;
}
bool BaseUIEInkDisplay::commit(Drivers::EInk::UpdateTypes type, bool blocking)
{
uint32_t hash = repack();
// Skip if frame unchanged. Exception: caller explicitly wants a refresh (COSMETIC or FULL).
if (hash == lastHash && type != Drivers::EInk::UpdateTypes::FULL && lastDrawMsec != 0)
return false;
lastHash = hash;
// Fall back to FULL on panels that don't advertise FAST support.
if (type == Drivers::EInk::UpdateTypes::FAST && !driver->supports(Drivers::EInk::UpdateTypes::FAST))
type = Drivers::EInk::UpdateTypes::FULL;
driver->update(panelBuffer, type);
if (blocking)
driver->await();
return true;
}
Drivers::EInk::UpdateTypes BaseUIEInkDisplay::decide()
{
typedef Drivers::EInk::UpdateTypes UT;
const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
// Explicit flag wins outright
if (frameFlags & COSMETIC) {
fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
return UT::FULL;
}
if (frameFlags & DEMAND_FAST) {
if (!unlimitedFast) {
fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
}
return UT::FAST;
}
const bool explicitFast = frameFlags & RESPONSIVE;
if (explicitFast || unlimitedFast) {
if (!unlimitedFast) {
fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
}
return UT::FAST;
}
// BACKGROUND / unspecified: let debt decide
if (fullRefreshDebt >= 1.0f) {
fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
return UT::FULL;
}
fullRefreshDebt += 1.0f / fastPerFull;
return UT::FAST;
}
uint32_t BaseUIEInkDisplay::repack()
{
memset(panelBuffer, 0xFF, panelBufferSize); // start all-white
const uint16_t pw = driver->width;
const uint16_t ph = driver->height;
// OLEDDisplay buffer: byte = buffer[x + (y/8) * displayWidth]; bit = 1 << (y & 7); 1 = black
// Niche buffer: byte = (y * panelRowBytes) + (x/8); bit = 1 << (7 - x%8); 1 = white
for (uint16_t oy = 0; oy < displayHeight; oy++) {
for (uint16_t ox = 0; ox < displayWidth; ox++) {
const uint8_t b = buffer[ox + (oy / 8) * displayWidth];
const bool isBlack = b & (1 << (oy & 7));
uint16_t px, py;
switch (rotation) {
case 1: // 90° CW: OLED (ox,oy) → panel (pw-1-oy, ox)
px = pw - 1 - oy;
py = ox;
break;
case 2: // 180°
px = pw - 1 - ox;
py = ph - 1 - oy;
break;
case 3: // 270° CW
px = oy;
py = ph - 1 - ox;
break;
case 0:
default:
px = ox;
py = oy;
break;
}
if (px >= pw || py >= ph)
continue;
const uint32_t byteNum = (py * panelRowBytes) + (px / 8);
const uint8_t bitNum = 7 - (px % 8);
if (isBlack)
panelBuffer[byteNum] &= ~(1 << bitNum);
else
panelBuffer[byteNum] |= (1 << bitNum);
}
}
// FNV-1a
uint32_t h = 2166136261u;
for (uint32_t i = 0; i < panelBufferSize; i++) {
h ^= panelBuffer[i];
h *= 16777619u;
}
return h;
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+98
View File
@@ -0,0 +1,98 @@
/*
OLEDDisplay adapter that routes BaseUI pixel output to a NicheGraphics::Drivers::EInk driver.
One adapter serves all E-Ink variants: the panel driver and orientation are injected at construction,
and FULL/FAST selection is made by the shared DisplayHealth model (same as InkHUD).
Replaces the per-board branching in EInkDisplay2 / EInkDynamicDisplay / EInkParallelDisplay.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "graphics/eink/Drivers/EInk.h"
#include <OLEDDisplay.h>
namespace NicheGraphics
{
class BaseUIEInkDisplay : public OLEDDisplay
{
public:
// Flags Screen.cpp sets via EINK_ADD_FRAMEFLAG before triggering a draw.
// Bits are combined; decided at render time.
enum frameFlagTypes : uint8_t {
BACKGROUND = (1 << 0), // Regular OLEDDisplayUi tick - no urgency, UNSPECIFIED
RESPONSIVE = (1 << 1), // User-driven refresh - prefer FAST
COSMETIC = (1 << 2), // Clean splash / wake-from-sleep - force FULL
DEMAND_FAST = (1 << 3), // Menu interaction - force FAST
BLOCKING = (1 << 4), // Wait for update to finish before returning
UNLIMITED_FAST = (1 << 5), // Suppress health-driven FULL promotion (typing modes)
};
BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation);
~BaseUIEInkDisplay() override;
// OLEDDisplay overrides
bool connect() override;
void display() override;
void sendCommand(uint8_t com) override { (void)com; }
int getBufferOffset(void) override { return 0; }
// BaseUI public API (same shape as the old EInkDynamicDisplay)
bool forceDisplay(uint32_t msecLimit = 1000);
void addFrameFlag(frameFlagTypes flag);
void joinAsyncRefresh();
void enableUnlimitedFastMode() { addFrameFlag(UNLIMITED_FAST); }
void disableUnlimitedFastMode() { frameFlags = (frameFlagTypes)(frameFlags & ~UNLIMITED_FAST); }
// Tuning, called once per panel profile
void setDisplayResilience(uint8_t fastPerFull, float stressMultiplier = 2.0f);
// Exposed so Screen.cpp / variants can read the rotation passed in at construction
uint8_t getRotation() const { return rotation; }
private:
// Perform an update now, unconditionally. Returns true if a frame was pushed to the driver.
bool commit(Drivers::EInk::UpdateTypes type, bool blocking);
// Convert OLEDDisplay's column-major buffer into the panel's row-major MSB-left buffer.
// Applies rotation. Returns the hash of the panel buffer for frame-skip comparison.
uint32_t repack();
// Decide FULL vs FAST based on current frame flags + accumulated debt.
Drivers::EInk::UpdateTypes decide();
Drivers::EInk *driver = nullptr;
uint8_t rotation = 0; // 0=0°, 1=90°CW, 2=180°, 3=270°CW
uint8_t *panelBuffer = nullptr;
uint32_t panelBufferSize = 0;
uint16_t panelRowBytes = 0;
frameFlagTypes frameFlags = BACKGROUND;
uint32_t lastDrawMsec = 0;
uint32_t lastHash = 0;
// DisplayHealth-style debt tracking
float fullRefreshDebt = 0.0f;
uint8_t fastPerFull = 7;
float stressMultiplier = 2.0f;
};
} // namespace NicheGraphics
// Compat macros used throughout Screen.cpp - route straight to the adapter.
#define EINK_ADD_FRAMEFLAG(display, flag) \
static_cast<NicheGraphics::BaseUIEInkDisplay *>(display)->addFrameFlag(NicheGraphics::BaseUIEInkDisplay::flag)
#define EINK_JOIN_ASYNCREFRESH(display) static_cast<NicheGraphics::BaseUIEInkDisplay *>(display)->joinAsyncRefresh()
#else // !MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#define EINK_ADD_FRAMEFLAG(display, flag)
#define EINK_JOIN_ASYNCREFRESH(display)
#endif
-298
View File
@@ -1,298 +0,0 @@
#include "configuration.h"
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
#include "EInkDisplay2.h"
#include "SPILock.h"
#include "main.h"
#include <SPI.h>
#ifdef GXEPD2_DRIVER_0
#include "einkDetect.h"
#endif
/*
The macros EINK_DISPLAY_MODEL, EINK_WIDTH, and EINK_HEIGHT are defined as build_flags in a variant's platformio.ini
Previously, these macros were defined at the top of this file.
For archival reasons, note that the following configurations had also been tested during this period:
* ifdef RAK4631
- 4.2 inch
EINK_DISPLAY_MODEL: GxEPD2_420_M01
EINK_WIDTH: 300
EINK_WIDTH: 400
- 2.9 inch
EINK_DISPLAY_MODEL: GxEPD2_290_T5D
EINK_WIDTH: 296
EINK_HEIGHT: 128
- 1.54 inch
EINK_DISPLAY_MODEL: GxEPD2_154_M09
EINK_WIDTH: 200
EINK_HEIGHT: 200
*/
// Constructor
EInkDisplay::EInkDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
{
// Set dimensions in OLEDDisplay base class
this->geometry = GEOMETRY_RAWMODE;
this->displayWidth = EINK_WIDTH;
this->displayHeight = EINK_HEIGHT;
// Round shortest side up to nearest byte, to prevent truncation causing an undersized buffer
uint16_t shortSide = min(EINK_WIDTH, EINK_HEIGHT);
uint16_t longSide = max(EINK_WIDTH, EINK_HEIGHT);
if (shortSide % 8 != 0)
shortSide = (shortSide | 7) + 1;
this->displayBufferSize = longSide * (shortSide / 8);
}
/**
* Force a display update if we haven't drawn within the specified msecLimit
*/
bool EInkDisplay::forceDisplay(uint32_t msecLimit)
{
// No need to grab this lock because we are on our own SPI bus
// concurrency::LockGuard g(spiLock);
uint32_t now = millis();
uint32_t sinceLast = now - lastDrawMsec;
if (adafruitDisplay && (sinceLast > msecLimit || lastDrawMsec == 0))
lastDrawMsec = now;
else
return false;
// FIXME - only draw bits have changed (use backbuf similar to the other displays)
const bool flipped = config.display.flip_screen;
// HACK for L1 EInk
#if defined(SEEED_WIO_TRACKER_L1_EINK)
// For SEEED_WIO_TRACKER_L1_EINK, setRotation(3) is correct but mirrored; flip both axes
for (uint32_t y = 0; y < displayHeight; y++) {
for (uint32_t x = 0; x < displayWidth; x++) {
auto b = buffer[x + (y / 8) * displayWidth];
auto isset = b & (1 << (y & 7));
adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE);
}
}
#else
for (uint32_t y = 0; y < displayHeight; y++) {
for (uint32_t x = 0; x < displayWidth; x++) {
auto b = buffer[x + (y / 8) * displayWidth];
auto isset = b & (1 << (y & 7));
if (flipped)
adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE);
else
adafruitDisplay->drawPixel(x, y, isset ? GxEPD_BLACK : GxEPD_WHITE);
}
}
#endif
// Trigger the refresh in GxEPD2
LOG_DEBUG("Update E-Paper");
adafruitDisplay->nextPage();
// End the update process
endUpdate();
LOG_DEBUG("done");
return true;
}
// End the update process - virtual method, overridden in derived class
void EInkDisplay::endUpdate()
{
#ifndef EINK_NOT_HIBERNATE
// By default, power off the E-Ink display hardware and enter hibernate().
// Boards/panels that define EINK_NOT_HIBERNATE intentionally skip this step.
// Skipping hibernate() can help avoid panel-specific wake/refresh or ghosting issues,
// but it typically trades lower power savings for that compatibility.
adafruitDisplay->hibernate();
#endif
}
// Write the buffer to the display memory
void EInkDisplay::display(void)
{
// We don't allow regular 'dumb' display() calls to draw on eink until we've shown
// at least one forceDisplay() keyframe. This prevents flashing when we should the critical
// bootscreen (that we want to look nice)
if (lastDrawMsec) {
forceDisplay(slowUpdateMsec); // Show the first screen a few seconds after boot, then slower
}
}
// Send a command to the display (low level function)
void EInkDisplay::sendCommand(uint8_t com)
{
(void)com;
// Drop all commands to device (we just update the buffer)
}
void EInkDisplay::setDetected(uint8_t detected)
{
(void)detected;
}
// Connect to the display - variant specific
bool EInkDisplay::connect()
{
LOG_INFO("Do EInk init");
#ifdef PIN_EINK_EN
// backlight power, HIGH is backlight on, LOW is off
pinMode(PIN_EINK_EN, OUTPUT);
#ifdef ELECROW_ThinkNode_M1
// ThinkNode M1 has a hardware dimmable backlight. Start enabled
digitalWrite(PIN_EINK_EN, HIGH);
#elif defined(MINI_EPAPER_S3)
// T-Mini Epaper S3 requires panel power rail enabled before SPI transfer.
digitalWrite(PIN_EINK_EN, HIGH);
delay(10);
#else
digitalWrite(PIN_EINK_EN, LOW);
#endif
#endif
#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init();
#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE)
adafruitDisplay->setRotation(4);
#else
adafruitDisplay->setRotation(3);
#endif
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
}
#elif defined(ELECROW_ThinkNode_M5)
{
// Start HSPI
hspi = new SPIClass(HSPI);
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init();
adafruitDisplay->setRotation(4);
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
}
#elif defined(MESHLINK)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init();
adafruitDisplay->setRotation(3);
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
}
#elif defined(RAK4630) || defined(MAKERPYTHON)
{
if (eink_found) {
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 10, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
// RAK14000 2.13 inch b/w 250x122 does actually now support fast refresh
adafruitDisplay->setRotation(3);
// Fast refresh support for 1.54, 2.13 RAK14000 b/w , 2.9 and 4.2
// adafruitDisplay->setRotation(1);
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
} else {
(void)adafruitDisplay;
}
}
#elif defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || \
defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || \
defined(MINI_EPAPER_S3)
{
// Start HSPI
hspi = new SPIClass(HSPI);
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
// VExt already enabled in setup()
// RTC GPIO hold disabled in setup()
// Create GxEPD2 objects
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
// Init GxEPD2
adafruitDisplay->init();
#if defined(MINI_EPAPER_S3)
adafruitDisplay->setRotation(3);
#else
adafruitDisplay->setRotation(3);
#if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER)
adafruitDisplay->setRotation(0);
#endif
#endif
}
#elif defined(PCA10059) || defined(ME25LS01)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 40, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
adafruitDisplay->setRotation(0);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
}
#elif defined(M5_COREINK) || defined(T_DECK_PRO)
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
adafruitDisplay->setRotation(0);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
#elif defined(my) || defined(ESP32_S3_PICO)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
adafruitDisplay->setRotation(1);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
}
#elif defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK)
{
spi1 = &SPI1;
spi1->begin();
// VExt already enabled in setup()
// RTC GPIO hold disabled in setup()
// Create GxEPD2 objects
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
// Init GxEPD2
adafruitDisplay->init();
adafruitDisplay->setRotation(3);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
}
#elif defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_VISION_MASTER_E213)
// Detect display model, before starting SPI
EInkDetectionResult displayModel = detectEInk();
// Start HSPI
hspi = new SPIClass(HSPI);
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
// Create GxEPD2 object
adafruitDisplay = new GxEPD2_Multi<GXEPD2_DRIVER_0, GXEPD2_DRIVER_1>((uint8_t)displayModel, PIN_EINK_CS, PIN_EINK_DC,
PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
// Init GxEPD2
adafruitDisplay->init();
adafruitDisplay->setRotation(3);
#endif
return true;
}
#endif
-106
View File
@@ -1,106 +0,0 @@
#pragma once
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
#include "GxEPD2_BW.h"
#include <OLEDDisplay.h>
#ifdef GXEPD2_DRIVER_0 // If variant has multiple possible display models
#include "GxEPD2Multi.h"
#endif
// Limit how often we push a full E-Ink refresh. T-Deck Pro needs faster updates for typing.
#ifndef EINK_FORCE_DISPLAY_THROTTLE_MS
#if defined(T_DECK_PRO)
#define EINK_FORCE_DISPLAY_THROTTLE_MS 200
#else
#define EINK_FORCE_DISPLAY_THROTTLE_MS 1000
#endif
#endif
/**
* An adapter class that allows using the GxEPD2 library as if it was an OLEDDisplay implementation.
*
* Note: EInkDynamicDisplay derives from this class.
*
* Remaining TODO:
* optimize display() to only draw changed pixels (see other OLED subclasses for examples)
* implement displayOn/displayOff to turn off the TFT device (and backlight)
* Use the fast NRF52 SPI API rather than the slow standard arduino version
*
* turn radio back on - currently with both on spi bus is fucked? or are we leaving chip select asserted?
* Suggestion: perhaps similar to HELTEC_WIRELESS_PAPER issue, which resolved with rtc_gpio_hold_dis()
*/
class EInkDisplay : public OLEDDisplay
{
/// How often should we update the display
/// thereafter we do once per 5 minutes
uint32_t slowUpdateMsec = 5 * 60 * 1000;
public:
/* constructor
FIXME - the parameters are not used, just a temporary hack to keep working like the old displays
*/
EInkDisplay(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C);
// Write the buffer to the display memory (for eink we only do this occasionally)
virtual void display(void) override;
/**
* Force a display update if we haven't drawn within the specified msecLimit
*
* @return true if we did draw the screen
*/
virtual bool forceDisplay(uint32_t msecLimit = EINK_FORCE_DISPLAY_THROTTLE_MS);
/**
* Run any code needed to complete an update, after the physical refresh has completed.
* Split from forceDisplay(), to enable async refresh in derived EInkDynamicDisplay class.
*
*/
virtual void endUpdate();
/**
* shim to make the abstraction happy
*
*/
void setDetected(uint8_t detected);
protected:
// the header size of the buffer used, e.g. for the SPI command header
virtual int getBufferOffset(void) override { return 0; }
// Send a command to the display (low level function)
virtual void sendCommand(uint8_t com) override;
// Connect to the display
virtual bool connect() override;
#ifdef GXEPD2_DRIVER_0
// AdafruitGFX display object - wrapper for multiple drivers
// Allows runtime detection of multiple displays
// Avoid this situation if possible!
GxEPD2_Multi<GXEPD2_DRIVER_0, GXEPD2_DRIVER_1> *adafruitDisplay = NULL;
#else
// AdafruitGFX display object (for single display model) - instantiated in connect(), variant specific
GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT> *adafruitDisplay = NULL;
#endif
// If display uses HSPI
#if defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E213) || \
defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || defined(CROWPANEL_ESP32S3_5_EPAPER) || \
defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) || \
defined(MINI_EPAPER_S3)
SPIClass *hspi = NULL;
#endif
#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK)
SPIClass *spi1 = NULL;
#endif
private:
// FIXME quick hack to limit drawing to a very slow rate
uint32_t lastDrawMsec = 0;
};
#endif
-564
View File
@@ -1,564 +0,0 @@
#include "Throttle.h"
#include "configuration.h"
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
#include "EInkDynamicDisplay.h"
// Constructor
EInkDynamicDisplay::EInkDynamicDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
: EInkDisplay(address, sda, scl, geometry, i2cBus), NotifiedWorkerThread("EInkDynamicDisplay")
{
// If tracking ghost pixels, grab memory
#ifdef EINK_LIMIT_GHOSTING_PX
dirtyPixels = std::unique_ptr<uint8_t[]>(new uint8_t[EInkDisplay::displayBufferSize]()); // Init with zeros
#endif
}
// Destructor
EInkDynamicDisplay::~EInkDynamicDisplay()
{
// If we were tracking ghost pixels, free the memory
#ifdef EINK_LIMIT_GHOSTING_PX
dirtyPixels = nullptr;
#endif
}
// Screen requests a BACKGROUND frame
void EInkDynamicDisplay::display()
{
addFrameFlag(BACKGROUND);
update();
}
// Screen requests a RESPONSIVE frame
bool EInkDynamicDisplay::forceDisplay(uint32_t msecLimit)
{
addFrameFlag(RESPONSIVE);
return update(); // (Unutilized) Base class promises to return true if update ran
}
// Add flag for the next frame
void EInkDynamicDisplay::addFrameFlag(frameFlagTypes flag)
{
// OR the new flag into the existing flags
this->frameFlags = (frameFlagTypes)(this->frameFlags | flag);
}
// GxEPD2 code to set fast refresh
void EInkDynamicDisplay::configForFastRefresh()
{
// Variant-specific code can go here
#if defined(PRIVATE_HW)
#else
// Otherwise:
adafruitDisplay->setPartialWindow(0, 0, adafruitDisplay->width(), adafruitDisplay->height());
#endif
}
// GxEPD2 code to set full refresh
void EInkDynamicDisplay::configForFullRefresh()
{
// Variant-specific code can go here
#if defined(PRIVATE_HW)
#else
// Otherwise:
adafruitDisplay->setFullWindow();
#endif
}
// Run any relevant GxEPD2 code, so next update will use correct refresh type
void EInkDynamicDisplay::applyRefreshMode()
{
// Change from FULL to FAST
if (currentConfig == FULL && refresh == FAST) {
configForFastRefresh();
currentConfig = FAST;
}
// Change from FAST back to FULL
else if (currentConfig == FAST && refresh == FULL) {
configForFullRefresh();
currentConfig = FULL;
}
}
// Update fastRefreshCount
void EInkDynamicDisplay::adjustRefreshCounters()
{
if (refresh == FAST)
fastRefreshCount++;
else if (refresh == FULL)
fastRefreshCount = 0;
}
// Trigger the display update by calling base class
bool EInkDynamicDisplay::update()
{
// Determine the refresh mode to use, and start the update
bool refreshApproved = determineMode();
if (refreshApproved) {
EInkDisplay::forceDisplay(0); // Bypass base class' own rate-limiting system
storeAndReset(); // Store the result of this loop for next time. Note: call *before* endOrDetach()
endOrDetach(); // endUpdate() right now, or set the async refresh flag (if FULL and HAS_EINK_ASYNCFULL)
} else
storeAndReset(); // No update, no post-update code, just store the results
return refreshApproved; // (Unutilized) Base class promises to return true if update ran
}
// Figure out who runs the post-update code
void EInkDynamicDisplay::endOrDetach()
{
// If the GxEPD2 version reports that it has the async modifications
#ifdef HAS_EINK_ASYNCFULL
if (previousRefresh == FULL) {
asyncRefreshRunning = true; // Set the flag - checked in determineMode(); cleared by onNotify()
if (previousFrameFlags & BLOCKING)
awaitRefresh();
else {
// Async begins
LOG_DEBUG("Async full-refresh begins (drop frames)");
notifyLater(intervalPollAsyncRefresh, DUE_POLL_ASYNCREFRESH, true); // Hand-off to NotifiedWorkerThread
}
}
// Fast Refresh
else if (previousRefresh == FAST)
EInkDisplay::endUpdate(); // Still block while updating, but EInkDisplay needs us to call endUpdate() ourselves.
// Fallback - If using an unmodified version of GxEPD2 for some reason
#else
if (previousRefresh == FULL || previousRefresh == FAST) { // If refresh wasn't skipped (on unspecified..)
LOG_WARN(
"GxEPD2 version has not been modified to support async refresh; using fallback behavior. Please update lib_deps in "
"variant's platformio.ini file");
EInkDisplay::endUpdate();
}
#endif
}
// Assess situation, pick a refresh type
bool EInkDynamicDisplay::determineMode()
{
checkInitialized();
checkForPromotion();
#if defined(HAS_EINK_ASYNCFULL)
checkBusyAsyncRefresh();
#endif
checkRateLimiting();
// If too soon for a new frame, or display busy, abort early
if (refresh == SKIPPED)
return false; // No refresh
// -- New frame is due --
resetRateLimiting(); // Once determineMode() ends, will have to wait again
hashImage(); // Generate here, so we can still copy it to previousImageHash, even if we skip the comparison check
LOG_DEBUG("determineMode(): "); // Begin log entry
// Once mode determined, any remaining checks will bypass
checkCosmetic();
checkDemandingFast();
checkFrameMatchesPrevious();
checkConsecutiveFastRefreshes();
#ifdef EINK_LIMIT_GHOSTING_PX
checkExcessiveGhosting();
#endif
checkFastRequested();
if (refresh == UNSPECIFIED)
LOG_WARN("There was a flaw in the determineMode() logic");
// -- Decision has been reached --
applyRefreshMode();
adjustRefreshCounters();
#ifdef EINK_LIMIT_GHOSTING_PX
// Full refresh clears any ghosting
if (refresh == FULL)
resetGhostPixelTracking();
#endif
// Return - call a refresh or not?
if (refresh == SKIPPED)
return false; // Don't trigger a refresh
else
return true; // Do trigger a refresh
}
// Is this the very first frame?
void EInkDynamicDisplay::checkInitialized()
{
if (!initialized) {
// Undo GxEPD2_BW::partialWindow(), if set by developer in EInkDisplay::connect()
configForFullRefresh();
// Clear any existing image, so we can draw logo with fast-refresh, but also to set GxEPD2_EPD::_initial_write
adafruitDisplay->clearScreen();
LOG_DEBUG("initialized, ");
initialized = true;
// Use a fast-refresh for the next frame; no skipping or else blank screen when waking from deep sleep
addFrameFlag(DEMAND_FAST);
}
}
// Was a frame skipped (rate, display busy) that should have been a FAST refresh?
void EInkDynamicDisplay::checkForPromotion()
{
// If a frame was skipped (rate, display busy), then promote a BACKGROUND frame
// Because we DID want a RESPONSIVE/COSMETIC/DEMAND_FULL frame last time, we just didn't get it
switch (previousReason) {
case ASYNC_REFRESH_BLOCKED_DEMANDFAST:
addFrameFlag(DEMAND_FAST);
break;
case ASYNC_REFRESH_BLOCKED_COSMETIC:
addFrameFlag(COSMETIC);
break;
case ASYNC_REFRESH_BLOCKED_RESPONSIVE:
case EXCEEDED_RATELIMIT_FAST:
addFrameFlag(RESPONSIVE);
break;
default:
break;
}
}
// Is it too soon for another frame of this type?
void EInkDynamicDisplay::checkRateLimiting()
{
// Sanity check: millis() overflow - just let the update run..
if (previousRunMs > millis())
return;
// Skip update: too soon for BACKGROUND
if (frameFlags == BACKGROUND) {
if (Throttle::isWithinTimespanMs(previousRunMs, 30000)) {
refresh = SKIPPED;
reason = EXCEEDED_RATELIMIT_FULL;
return;
}
}
// No rate-limit for these special cases
if (frameFlags & COSMETIC || frameFlags & DEMAND_FAST)
return;
// Skip update: too soon for RESPONSIVE
if (frameFlags & RESPONSIVE) {
if (Throttle::isWithinTimespanMs(previousRunMs, 1000)) {
refresh = SKIPPED;
reason = EXCEEDED_RATELIMIT_FAST;
LOG_DEBUG("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags);
return;
}
}
}
// Is this frame COSMETIC (splash screens?)
void EInkDynamicDisplay::checkCosmetic()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// A full refresh is requested for cosmetic purposes: we have a decision
if (frameFlags & COSMETIC) {
refresh = FULL;
reason = FLAGGED_COSMETIC;
LOG_DEBUG("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags);
}
}
// Is this a one-off special circumstance, where we REALLY want a fast refresh?
void EInkDynamicDisplay::checkDemandingFast()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// A fast refresh is demanded: we have a decision
if (frameFlags & DEMAND_FAST) {
refresh = FAST;
reason = FLAGGED_DEMAND_FAST;
LOG_DEBUG("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags);
}
}
// Does the new frame match the currently displayed image?
void EInkDynamicDisplay::checkFrameMatchesPrevious()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// If frame is *not* a duplicate, abort the check
if (imageHash != previousImageHash)
return;
#if !defined(EINK_BACKGROUND_USES_FAST)
// If BACKGROUND, and last update was FAST: redraw the same image in FULL (for display health + image quality)
if (frameFlags == BACKGROUND && fastRefreshCount > 0) {
refresh = FULL;
reason = REDRAW_WITH_FULL;
LOG_DEBUG("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags);
return;
}
#endif
// Not redrawn, not COSMETIC, not DEMAND_FAST
refresh = SKIPPED;
reason = FRAME_MATCHED_PREVIOUS;
LOG_DEBUG("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags);
}
// Have too many fast-refreshes occurred consecutively, since last full refresh?
void EInkDynamicDisplay::checkConsecutiveFastRefreshes()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// Bypass limit if UNLIMITED_FAST mode is active
if (frameFlags & UNLIMITED_FAST) {
refresh = FAST;
reason = NO_OBJECTIONS;
LOG_DEBUG("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags);
return;
}
// If too many FAST refreshes consecutively - force a FULL refresh
if (fastRefreshCount >= EINK_LIMIT_FASTREFRESH) {
refresh = FULL;
reason = EXCEEDED_LIMIT_FASTREFRESH;
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags);
}
}
// No objections, we can perform fast-refresh, if desired
void EInkDynamicDisplay::checkFastRequested()
{
if (refresh != UNSPECIFIED)
return;
if (frameFlags == BACKGROUND) {
#ifdef EINK_BACKGROUND_USES_FAST
// If we want BACKGROUND to use fast. (FULL only when a limit is hit)
refresh = FAST;
reason = BACKGROUND_USES_FAST;
LOG_DEBUG("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount,
frameFlags);
#else
// If we do want to use FULL for BACKGROUND updates
refresh = FULL;
reason = FLAGGED_BACKGROUND;
LOG_DEBUG("refresh=FULL, reason=FLAGGED_BACKGROUND");
#endif
}
// Sanity: confirm that we did ask for a RESPONSIVE frame.
if (frameFlags & RESPONSIVE) {
refresh = FAST;
reason = NO_OBJECTIONS;
LOG_DEBUG("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags);
}
}
// Reset the timer used for rate-limiting
void EInkDynamicDisplay::resetRateLimiting()
{
previousRunMs = millis();
}
// Generate a hash of this frame, to compare against previous update
void EInkDynamicDisplay::hashImage()
{
imageHash = 0;
// Sum all bytes of the image buffer together
for (uint16_t b = 0; b < (displayWidth / 8) * displayHeight; b++) {
imageHash ^= buffer[b] << b;
}
}
// Store the results of determineMode() for future use, and reset for next call
void EInkDynamicDisplay::storeAndReset()
{
previousFrameFlags = frameFlags;
previousRefresh = refresh;
previousReason = reason;
// Only store image hash if the display will update
if (refresh != SKIPPED) {
previousImageHash = imageHash;
}
frameFlags = BACKGROUND;
refresh = UNSPECIFIED;
}
#ifdef EINK_LIMIT_GHOSTING_PX
// Count how many ghost pixels the new image will display
void EInkDynamicDisplay::countGhostPixels()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// Start a new count
ghostPixelCount = 0;
// Check new image, bit by bit, for any white pixels at locations marked "dirty"
for (uint16_t i = 0; i < displayBufferSize; i++) {
for (uint8_t bit = 0; bit < 7; bit++) {
const bool dirty = (dirtyPixels[i] >> bit) & 1; // Has pixel location been drawn to since full-refresh?
const bool shouldBeBlank = !((buffer[i] >> bit) & 1); // Is pixel location white in the new image?
// If pixel is (or has been) black since last full-refresh, and now is white: ghosting
if (dirty && shouldBeBlank)
ghostPixelCount++;
// Update the dirty status for this pixel - will this location become a ghost if set white in future?
if (!dirty && !shouldBeBlank)
dirtyPixels[i] |= (1 << bit);
}
}
LOG_DEBUG("ghostPixels=%hu, ", ghostPixelCount);
}
// Check if ghost pixel count exceeds the defined limit
void EInkDynamicDisplay::checkExcessiveGhosting()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
countGhostPixels();
// If too many ghost pixels, select full refresh
if (ghostPixelCount > EINK_LIMIT_GHOSTING_PX) {
refresh = FULL;
reason = EXCEEDED_GHOSTINGLIMIT;
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags);
}
}
// Clear the dirty pixels array. Call when full-refresh cleans the display.
void EInkDynamicDisplay::resetGhostPixelTracking()
{
// Copy the current frame into dirtyPixels[] from the display buffer
memcpy(dirtyPixels.get(), EInkDisplay::buffer, EInkDisplay::displayBufferSize);
}
#endif // EINK_LIMIT_GHOSTING_PX
// Handle any asyc tasks
void EInkDynamicDisplay::onNotify(uint32_t notification)
{
// Which task
switch (notification) {
case DUE_POLL_ASYNCREFRESH:
pollAsyncRefresh();
break;
}
}
#ifdef HAS_EINK_ASYNCFULL
// Public: wait for an refresh already in progress, then run the post-update code. See Screen::setScreensaverFrames()
void EInkDynamicDisplay::joinAsyncRefresh()
{
// If no async refresh running, nothing to do
if (!asyncRefreshRunning)
return;
LOG_DEBUG("Join an async refresh in progress");
// Continually poll the BUSY pin
while (adafruitDisplay->epd2.isBusy())
yield();
// If asyncRefreshRunning flag is still set, but display's BUSY pin reports the refresh is done
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
asyncRefreshRunning = false; // Unset the flag
LOG_DEBUG("Refresh complete");
// Note: this code only works because of a modification to meshtastic/GxEPD2.
// It is only equipped to intercept calls to nextPage()
}
// Called from NotifiedWorkerThread. Run the post-update code if the hardware is ready
void EInkDynamicDisplay::pollAsyncRefresh()
{
// In theory, this condition should never be met
if (!asyncRefreshRunning)
return;
// Still running, check back later
if (adafruitDisplay->epd2.isBusy()) {
// Schedule next call of pollAsyncRefresh()
NotifiedWorkerThread::notifyLater(intervalPollAsyncRefresh, DUE_POLL_ASYNCREFRESH, true);
return;
}
// If asyncRefreshRunning flag is still set, but display's BUSY pin reports the refresh is done
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
asyncRefreshRunning = false; // Unset the flag
LOG_DEBUG("Async full-refresh complete");
// Note: this code only works because of a modification to meshtastic/GxEPD2.
// It is only equipped to intercept calls to nextPage()
}
// Check the status of "async full-refresh"; skip if running
void EInkDynamicDisplay::checkBusyAsyncRefresh()
{
// No refresh taking place, continue with determineMode()
if (!asyncRefreshRunning)
return;
// Full refresh still running
if (adafruitDisplay->epd2.isBusy()) {
// No refresh
refresh = SKIPPED;
// Set the reason, marking what type of frame we're skipping
if (frameFlags & DEMAND_FAST)
reason = ASYNC_REFRESH_BLOCKED_DEMANDFAST;
else if (frameFlags & COSMETIC)
reason = ASYNC_REFRESH_BLOCKED_COSMETIC;
else if (frameFlags & RESPONSIVE)
reason = ASYNC_REFRESH_BLOCKED_RESPONSIVE;
else
reason = ASYNC_REFRESH_BLOCKED_BACKGROUND;
return;
}
// Async refresh appears to have stopped, but wasn't caught by onNotify()
else
pollAsyncRefresh(); // Check (and terminate) the async refresh manually
}
// Hold control while an async refresh runs
void EInkDynamicDisplay::awaitRefresh()
{
// Continually poll the BUSY pin
while (adafruitDisplay->epd2.isBusy())
yield();
// End the full-refresh process
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
asyncRefreshRunning = false; // Unset the flag
}
#endif // HAS_EINK_ASYNCFULL
#endif // USE_EINK_DYNAMICDISPLAY
-155
View File
@@ -1,155 +0,0 @@
#pragma once
#include "configuration.h"
#include <memory>
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
#include "EInkDisplay2.h"
#include "GxEPD2_BW.h"
#include "concurrency/NotifiedWorkerThread.h"
/*
Derives from the EInkDisplay adapter class.
Accepts suggestions from Screen class about frame type.
Determines which refresh type is most suitable.
(Full, Fast, Skip)
*/
class EInkDynamicDisplay : public EInkDisplay, protected concurrency::NotifiedWorkerThread
{
public:
// Constructor
// ( Parameters unused, passed to EInkDisplay. Maintains compatibility OLEDDisplay class )
EInkDynamicDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus);
~EInkDynamicDisplay();
// Methods to enable or disable unlimited fast refresh mode
void enableUnlimitedFastMode() { addFrameFlag(UNLIMITED_FAST); }
void disableUnlimitedFastMode() { frameFlags = (frameFlagTypes)(frameFlags & ~UNLIMITED_FAST); }
// What kind of frame is this
enum frameFlagTypes : uint8_t {
BACKGROUND = (1 << 0), // For frames via display()
RESPONSIVE = (1 << 1), // For frames via forceDisplay()
COSMETIC = (1 << 2), // For splashes
DEMAND_FAST = (1 << 3), // Special case only
BLOCKING = (1 << 4), // Modifier - block while refresh runs
UNLIMITED_FAST = (1 << 5)
};
void addFrameFlag(frameFlagTypes flag);
// Set the correct frame flag, then call universal "update()" method
void display() override;
bool forceDisplay(uint32_t msecLimit) override; // Shadows base class. Parameter and return val unused.
protected:
enum refreshTypes : uint8_t { // Which refresh operation will be used
UNSPECIFIED,
FULL,
FAST,
SKIPPED,
};
enum reasonTypes : uint8_t { // How was the decision reached
NO_OBJECTIONS,
ASYNC_REFRESH_BLOCKED_DEMANDFAST,
ASYNC_REFRESH_BLOCKED_COSMETIC,
ASYNC_REFRESH_BLOCKED_RESPONSIVE,
ASYNC_REFRESH_BLOCKED_BACKGROUND,
EXCEEDED_RATELIMIT_FAST,
EXCEEDED_RATELIMIT_FULL,
FLAGGED_COSMETIC,
FLAGGED_DEMAND_FAST,
EXCEEDED_LIMIT_FASTREFRESH,
EXCEEDED_GHOSTINGLIMIT,
FRAME_MATCHED_PREVIOUS,
BACKGROUND_USES_FAST,
FLAGGED_BACKGROUND,
REDRAW_WITH_FULL,
};
enum notificationTypes : uint8_t { // What was onNotify() called for
NONE = 0, // This behavior (NONE=0) is fixed by NotifiedWorkerThread class
DUE_POLL_ASYNCREFRESH = 1,
};
const uint32_t intervalPollAsyncRefresh = 100;
void onNotify(uint32_t notification) override; // Handle any async tasks - overrides NotifiedWorkerThread
void configForFastRefresh(); // GxEPD2 code to set fast-refresh
void configForFullRefresh(); // GxEPD2 code to set full-refresh
bool determineMode(); // Assess situation, pick a refresh type
void applyRefreshMode(); // Run any relevant GxEPD2 code, so next update will use correct refresh type
void adjustRefreshCounters(); // Update fastRefreshCount
bool update(); // Trigger the display update - determine mode, then call base class
void endOrDetach(); // Run the post-update code, or delegate it off to checkBusyAsyncRefresh()
// Checks as part of determineMode()
void checkInitialized(); // Is this the very first frame?
void checkForPromotion(); // Was a frame skipped (rate, display busy) that should have been a FAST refresh?
void checkRateLimiting(); // Is this frame too soon?
void checkCosmetic(); // Was the COSMETIC flag set?
void checkDemandingFast(); // Was the DEMAND_FAST flag set?
void checkFrameMatchesPrevious(); // Does the new frame match the existing display image?
void checkConsecutiveFastRefreshes(); // Too many fast-refreshes consecutively?
void checkFastRequested(); // Was the flag set for RESPONSIVE, or only BACKGROUND?
void resetRateLimiting(); // Set previousRunMs - this now counts as an update, for rate-limiting
void hashImage(); // Generate a hashed version of this frame, to compare against previous update
void storeAndReset(); // Keep results of determineMode() for later, tidy-up for next call
// What we are determining for this frame
frameFlagTypes frameFlags = BACKGROUND; // Frame characteristics - determineMode() input
refreshTypes refresh = UNSPECIFIED; // Refresh type - determineMode() output
reasonTypes reason = NO_OBJECTIONS; // Reason - why was refresh type used
// What happened last time determineMode() ran
frameFlagTypes previousFrameFlags = BACKGROUND; // (Previous) Frame flags
refreshTypes previousRefresh = UNSPECIFIED; // (Previous) Outcome
reasonTypes previousReason = NO_OBJECTIONS; // (Previous) Reason
bool initialized = false; // Have we drawn at least one frame yet?
uint32_t previousRunMs = -1; // When did determineMode() last run (rather than rejecting for rate-limiting)
uint32_t imageHash = 0; // Hash of the current frame. Don't bother updating if nothing has changed!
uint32_t previousImageHash = 0; // Hash of the previous update's frame
uint32_t fastRefreshCount = 0; // How many fast-refreshes consecutively since last full refresh?
refreshTypes currentConfig = FULL; // Which refresh type is GxEPD2 currently configured for
// Optional - track ghosting, pixel by pixel
// May 2024: no longer used by any display. Kept for possible future use.
#ifdef EINK_LIMIT_GHOSTING_PX
void countGhostPixels(); // Count any pixels which have moved from black to white since last full-refresh
void checkExcessiveGhosting(); // Check if ghosting exceeds defined limit
void resetGhostPixelTracking(); // Clear the dirty pixels array. Call when full-refresh cleans the display.
std::unique_ptr<uint8_t[]> dirtyPixels; // Any pixels that have been black since last full-refresh (dynamically allocated mem)
uint32_t ghostPixelCount = 0; // Number of pixels with problematic ghosting. Retained here for LOG_DEBUG use
#endif
// Conditional - async full refresh - only with modified meshtastic/GxEPD2
#if defined(HAS_EINK_ASYNCFULL)
public:
void joinAsyncRefresh(); // Main thread joins an async refresh already in progress. Blocks, then runs post-update code
protected:
void pollAsyncRefresh(); // Run the post-update code if the hardware is ready
void checkBusyAsyncRefresh(); // Check if display is busy running an async full-refresh (rejecting new frames)
void awaitRefresh(); // Hold control while an async refresh runs
void endUpdate() override {} // Disable base-class behavior of running post-update immediately after forceDisplay()
bool asyncRefreshRunning = false; // Flag, checked by checkBusyAsyncRefresh()
#else
public:
void joinAsyncRefresh() {} // Dummy method
protected:
void pollAsyncRefresh() {} // Dummy method. In theory, not reachable
#endif
};
// Hide the ugly casts used in Screen.cpp
#define EINK_ADD_FRAMEFLAG(display, flag) static_cast<EInkDynamicDisplay *>(display)->addFrameFlag(EInkDynamicDisplay::flag)
#define EINK_JOIN_ASYNCREFRESH(display) static_cast<EInkDynamicDisplay *>(display)->joinAsyncRefresh()
#else // !USE_EINK_DYNAMICDISPLAY
// Dummy-macro, removes the need for include guards
#define EINK_ADD_FRAMEFLAG(display, flag)
#define EINK_JOIN_ASYNCREFRESH(display)
#endif
-427
View File
@@ -1,427 +0,0 @@
#include "EInkParallelDisplay.h"
#ifdef USE_EINK_PARALLELDISPLAY
#include "Wire.h"
#include "variant.h"
#include <Arduino.h>
#include <atomic>
#include <stdlib.h>
#include <string.h>
#include "FastEPD.h"
// Thresholds for choosing partial vs full update
#ifndef EPD_PARTIAL_THRESHOLD_ROWS
#define EPD_PARTIAL_THRESHOLD_ROWS 128 // if changed region <= this many rows, prefer partial
#endif
#ifndef EPD_FULLSLOW_PERIOD
#define EPD_FULLSLOW_PERIOD 100 // every N full updates do a slow (CLEAR_SLOW) full refresh
#endif
#ifndef EPD_RESPONSIVE_MIN_MS
#define EPD_RESPONSIVE_MIN_MS 1000 // simple rate-limit (ms) for responsive updates
#endif
EInkParallelDisplay::EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rot) : epaper(nullptr), rotation(rot)
{
LOG_INFO("init EInkParallelDisplay");
// Set dimensions in OLEDDisplay base class
this->geometry = GEOMETRY_RAWMODE;
this->displayWidth = width;
this->displayHeight = height;
// Round shortest side up to nearest byte, to prevent truncation causing an undersized buffer
uint16_t shortSide = min(width, height);
uint16_t longSide = max(width, height);
if (shortSide % 8 != 0)
shortSide = (shortSide | 7) + 1;
this->displayBufferSize = longSide * (shortSide / 8);
#ifdef EINK_LIMIT_GHOSTING_PX
// allocate dirty pixel buffer same size as epaper buffers (rowBytes * height)
size_t rowBytes = (this->displayWidth + 7) / 8;
dirtyPixelsSize = rowBytes * this->displayHeight;
dirtyPixels = (uint8_t *)calloc(dirtyPixelsSize, 1);
ghostPixelCount = 0;
#endif
}
EInkParallelDisplay::~EInkParallelDisplay()
{
#ifdef EINK_LIMIT_GHOSTING_PX
if (dirtyPixels) {
free(dirtyPixels);
dirtyPixels = nullptr;
}
#endif
// If an async full update is running, wait for it to finish
if (asyncFullRunning.load()) {
// wait a short while for task to finish
for (int i = 0; i < 50 && asyncFullRunning.load(); ++i) {
delay(50);
}
if (asyncTaskHandle) {
// Let it finish or delete it
vTaskDelete(asyncTaskHandle);
asyncTaskHandle = nullptr;
}
}
delete epaper;
}
/*
* Called by the OLEDDisplay::init() path.
*/
bool EInkParallelDisplay::connect()
{
LOG_INFO("Do EPD init");
if (!epaper) {
epaper = new FASTEPD;
#if defined(T5_S3_EPAPER_PRO_V1)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
// initialize all port 0 pins (0-7) as outputs / HIGH
for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT);
epaper->ioWrite(i, HIGH);
}
#else
#error "unsupported EPD device!"
#endif
}
// epaper->setRotation(rotation); // does not work, messes up width/height
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true);
#ifdef EINK_LIMIT_GHOSTING_PX
// After a full/clear the dirty tracking should be reset
resetGhostPixelTracking();
#endif
return true;
}
/*
* sendCommand - simple passthrough (not required for epd_driver-based path)
*/
void EInkParallelDisplay::sendCommand(uint8_t com)
{
LOG_DEBUG("EInkParallelDisplay::sendCommand %d", (int)com);
}
/*
* Start a background task that will perform a blocking fullUpdate(). This lets
* display() return quickly while the heavy refresh runs in the background.
*/
void EInkParallelDisplay::startAsyncFullUpdate(int clearMode)
{
if (asyncFullRunning.load())
return; // already running
asyncFullRunning.store(true);
// pass 'this' as parameter
BaseType_t rc = xTaskCreatePinnedToCore(EInkParallelDisplay::asyncFullUpdateTask, "epd_full", 4096 / sizeof(StackType_t),
this, 2, &asyncTaskHandle,
#if CONFIG_FREERTOS_UNICORE
0
#else
1
#endif
);
if (rc != pdPASS) {
LOG_WARN("Failed to create async full-update task, falling back to blocking update");
epaper->fullUpdate(clearMode, false);
epaper->backupPlane();
asyncFullRunning.store(false);
asyncTaskHandle = nullptr;
}
}
/*
* FreeRTOS task entry: runs the full update and then backs up plane.
*/
void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters)
{
EInkParallelDisplay *self = static_cast<EInkParallelDisplay *>(pvParameters);
if (!self) {
vTaskDelete(nullptr);
return;
}
// choose CLEAR_SLOW occasionally
int clearMode = CLEAR_FAST;
if (self->fastRefreshCount >= EPD_FULLSLOW_PERIOD) {
clearMode = CLEAR_SLOW;
self->fastRefreshCount = 0;
} else {
// when running async full, treat it as a full so reset fast count
self->fastRefreshCount = 0;
}
self->epaper->fullUpdate(clearMode, false);
self->epaper->backupPlane();
#ifdef EINK_LIMIT_GHOSTING_PX
// A full refresh clears ghosting state
self->resetGhostPixelTracking();
#endif
self->asyncFullRunning.store(false);
self->asyncTaskHandle = nullptr;
// delete this task
vTaskDelete(nullptr);
}
/*
* Convert the OLEDDisplay buffer (vertical byte layout) into the 1bpp horizontal-bytes
* buffer used by the FASTEPD library. For performance we write directly into FASTEPD's
* currentBuffer() while comparing against previousBuffer() to detect changed rows.
* After conversion we call FASTEPD::partialUpdate() or FASTEPD::fullUpdate() according
* to a heuristic so only the minimal region is refreshed.
*/
void EInkParallelDisplay::display(void)
{
const uint16_t w = this->displayWidth;
const uint16_t h = this->displayHeight;
// Simple rate limiting: avoid very-frequent responsive updates
uint32_t nowMs = millis();
if (lastUpdateMs != 0 && (nowMs - lastUpdateMs) < EPD_RESPONSIVE_MIN_MS) {
LOG_DEBUG("rate-limited, skipping update");
return;
}
// bytes per row in epd format (one byte = 8 horizontal pixels)
const uint32_t rowBytes = (w + 7) / 8;
// Get pointers to internal buffers
uint8_t *cur = epaper->currentBuffer();
const uint8_t *prev = epaper->previousBuffer(); // may be NULL on first init
// Track changed row range while converting
int newTop = h; // min changed row (initialized to out-of-range)
int newBottom = -1; // max changed row
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// Track changed byte column range (for clipped fullUpdate fallback)
int newLeftByte = (int)rowBytes;
int newRightByte = -1;
#endif
// Compute a quick hash of the incoming OLED buffer (so we can skip identical frames)
uint32_t imageHash = 0;
uint32_t bufBytes = (w / 8) * h; // vertical-byte layout size
for (uint32_t bi = 0; bi < bufBytes; ++bi) {
imageHash ^= ((uint32_t)buffer[bi]) << (bi & 31);
}
if (imageHash == previousImageHash) {
// LOG_DEBUG("image identical to previous, skipping update");
return;
}
#ifdef EINK_LIMIT_GHOSTING_PX
// reset ghost count for this conversion pass; we'll mark bits that change
ghostPixelCount = 0;
#endif
// Convert: OLED buffer layout -> FASTEPD 1bpp horizontal-bytes layout into cur,
// comparing against prev when available to detect changes.
for (uint32_t y = 0; y < h; ++y) {
const uint32_t base = (y >> 3) * w; // (y/8) * width
const uint8_t bitMask = (uint8_t)(1u << (y & 7)); // mask for this row in vertical-byte layout
const uint32_t rowBase = y * rowBytes;
// process full 8-pixel bytes
for (uint32_t xb = 0; xb < rowBytes; ++xb) {
uint32_t x0 = xb * 8;
// read up to 8 source bytes (vertical-byte per column)
uint8_t b0 = (x0 + 0 < w) ? buffer[base + x0 + 0] : 0;
uint8_t b1 = (x0 + 1 < w) ? buffer[base + x0 + 1] : 0;
uint8_t b2 = (x0 + 2 < w) ? buffer[base + x0 + 2] : 0;
uint8_t b3 = (x0 + 3 < w) ? buffer[base + x0 + 3] : 0;
uint8_t b4 = (x0 + 4 < w) ? buffer[base + x0 + 4] : 0;
uint8_t b5 = (x0 + 5 < w) ? buffer[base + x0 + 5] : 0;
uint8_t b6 = (x0 + 6 < w) ? buffer[base + x0 + 6] : 0;
uint8_t b7 = (x0 + 7 < w) ? buffer[base + x0 + 7] : 0;
// build output byte: MSB = leftmost pixel
uint8_t out = 0;
out |= (uint8_t)((b0 & bitMask) ? 0x80 : 0x00);
out |= (uint8_t)((b1 & bitMask) ? 0x40 : 0x00);
out |= (uint8_t)((b2 & bitMask) ? 0x20 : 0x00);
out |= (uint8_t)((b3 & bitMask) ? 0x10 : 0x00);
out |= (uint8_t)((b4 & bitMask) ? 0x08 : 0x00);
out |= (uint8_t)((b5 & bitMask) ? 0x04 : 0x00);
out |= (uint8_t)((b6 & bitMask) ? 0x02 : 0x00);
out |= (uint8_t)((b7 & bitMask) ? 0x01 : 0x00);
// handle partial byte at end of row by masking off invalid bits
uint8_t mask = 0xFF;
uint32_t bitsRemain = (w > x0) ? (w - x0) : 0;
if (bitsRemain > 0 && bitsRemain < 8) {
mask = (uint8_t)(0xFF << (8 - bitsRemain));
out &= mask;
}
// invert to FASTEPD polarity
out = (~out) & mask;
uint32_t pos = rowBase + xb;
uint8_t prevVal = prev ? (prev[pos] & mask) : 0x00;
// Consider this byte changed if previous buffer differs (or prev is null)
bool changed = (prev == nullptr) || (prevVal != out);
#ifdef EINK_LIMIT_GHOSTING_PX
if (changed && prev)
markDirtyBits(prev, pos, mask, out);
#endif
// mark row changed only if the previous buffer differs
if (changed) {
if (y < (uint32_t)newTop)
newTop = y;
if ((int)y > newBottom)
newBottom = y;
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// record changed column bytes
if ((int)xb < newLeftByte)
newLeftByte = (int)xb;
if ((int)xb > newRightByte)
newRightByte = (int)xb;
#endif
}
// Always write the computed value into the current buffer (avoid leaving stale bytes)
cur[pos] = (cur[pos] & ~mask) | out;
}
}
// If nothing changed, avoid any panel update
if (newBottom < 0) {
LOG_DEBUG("no pixel changes detected, skipping update (conv)");
previousImageHash = imageHash; // still remember that frame
return;
}
// Choose partial vs full update using heuristic
// Decide if we should force a full update after many fast updates
bool forceFull = (fastRefreshCount >= EPD_FULLSLOW_PERIOD);
#ifdef EINK_LIMIT_GHOSTING_PX
// If ghost pixels exceed limit, force a full update to clear ghosting
if (ghostPixelCount > ghostPixelLimit) {
LOG_WARN("ghost pixels %u > limit %u, forcing full refresh", ghostPixelCount, ghostPixelLimit);
forceFull = true;
}
#endif
// Compute pixel bounds from newTop/newBottom
int startRow = (newTop / 8) * 8;
int endRow = (newBottom / 8) * 8 + 7;
LOG_DEBUG("EPD update rows=%d..%d alignedRows=%d..%d rowBytes=%u", newTop, newBottom, startRow, endRow, rowBytes);
if (epaper->getMode() == BB_MODE_1BPP && !forceFull && (newBottom - newTop) <= EPD_PARTIAL_THRESHOLD_ROWS) {
// Prefer partial update path if driver is reliable; otherwise use clipped fullUpdate fallback.
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// Workaround for FastEPD partial update bug: use clipped fullUpdate instead
// Build a pixel rectangle for a clipped fullUpdate using the changed columns
int startCol = (newLeftByte <= newRightByte) ? (newLeftByte * 8) : 0;
int endCol = (newLeftByte <= newRightByte) ? ((newRightByte + 1) * 8 - 1) : (w - 1);
BB_RECT rect{startCol, startRow, endCol - startCol + 1, endRow - startRow + 1};
// LOG_DEBUG("Using clipped fullUpdate rect x=%d y=%d w=%d h=%d", rect.x, rect.y, rect.w, rect.h);
epaper->fullUpdate(CLEAR_FAST, false, &rect);
#else
// Use rows for partial update
LOG_DEBUG("calling partialUpdate startRow=%d endRow=%d", startRow, endRow);
epaper->partialUpdate(true, startRow, endRow);
#endif
epaper->backupPlane();
fastRefreshCount++;
} else {
// Full update: run async if possible (startAsyncFullUpdate will fall back to blocking)
startAsyncFullUpdate(forceFull ? CLEAR_SLOW : CLEAR_FAST);
}
lastUpdateMs = millis();
previousImageHash = imageHash;
// Keep same behavior as before
lastDrawMsec = millis();
}
#ifdef EINK_LIMIT_GHOSTING_PX
// markDirtyBits: mark per-bit dirty flags and update ghostPixelCount
void EInkParallelDisplay::markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out)
{
// defensive: need dirtyPixels allocated and prevBuf valid
if (!dirtyPixels || !prevBuf)
return;
// 'out' is in FASTEPD polarity (1 = black, 0 = white)
uint8_t newBlack = out & mask; // bits that will be black now
uint8_t newWhite = (~out) & mask; // bits that will be white now
// previously recorded dirty bits for this byte
uint8_t before = dirtyPixels[pos];
// Ghost bits: bits that were previously marked dirty and are now being driven white
uint8_t ghostBits = before & newWhite;
if (ghostBits) {
ghostPixelCount += __builtin_popcount((unsigned)ghostBits);
}
// Only mark bits dirty when they turn black now (accumulate until a full refresh)
uint8_t newlyDirty = newBlack & (~before);
if (newlyDirty) {
dirtyPixels[pos] |= newlyDirty;
}
}
// reset ghost tracking (call after a full refresh)
void EInkParallelDisplay::resetGhostPixelTracking()
{
if (!dirtyPixels)
return;
memset(dirtyPixels, 0, dirtyPixelsSize);
ghostPixelCount = 0;
}
#endif
/*
* forceDisplay: use lastDrawMsec
*/
bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
{
uint32_t now = millis();
if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) {
display();
return true;
}
return false;
}
void EInkParallelDisplay::endUpdate()
{
{
// ensure any async full update is started/completed
if (asyncFullRunning.load()) {
// nothing to do; background task will run and call backupPlane when done
} else {
epaper->fullUpdate(CLEAR_FAST, false);
epaper->backupPlane();
#ifdef EINK_LIMIT_GHOSTING_PX
resetGhostPixelTracking();
#endif
}
}
}
#endif
-69
View File
@@ -1,69 +0,0 @@
#pragma once
#include "configuration.h"
#ifdef USE_EINK_PARALLELDISPLAY
#include <OLEDDisplay.h>
#include <atomic>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
class FASTEPD;
/**
* Adapter for E-Ink 8-bit parallel displays (EPD), specifically devices supported by FastEPD library
*/
class EInkParallelDisplay : public OLEDDisplay
{
public:
enum EpdRotation {
EPD_ROT_LANDSCAPE = 0,
EPD_ROT_PORTRAIT = 90,
EPD_ROT_INVERTED_LANDSCAPE = 180,
EPD_ROT_INVERTED_PORTRAIT = 270,
};
EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rotation);
virtual ~EInkParallelDisplay();
// OLEDDisplay virtuals
bool connect() override;
void sendCommand(uint8_t com) override;
int getBufferOffset(void) override { return 0; }
void display(void) override;
bool forceDisplay(uint32_t msecLimit = 1000);
void endUpdate();
protected:
uint32_t lastDrawMsec = 0;
FASTEPD *epaper;
private:
// Async full-refresh support
std::atomic<bool> asyncFullRunning{false};
TaskHandle_t asyncTaskHandle = nullptr;
void startAsyncFullUpdate(int clearMode);
static void asyncFullUpdateTask(void *pvParameters);
#ifdef EINK_LIMIT_GHOSTING_PX
// helpers
void resetGhostPixelTracking();
void markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out);
void countGhostPixelsAndMaybePromote(int &newTop, int &newBottom, bool &forceFull);
// per-bit dirty buffer (same format as epaper buffers): one bit == one pixel
uint8_t *dirtyPixels = nullptr;
size_t dirtyPixelsSize = 0;
uint32_t ghostPixelCount = 0;
uint32_t ghostPixelLimit = EINK_LIMIT_GHOSTING_PX;
#endif
EpdRotation rotation;
uint32_t previousImageHash = 0;
uint32_t lastUpdateMs = 0;
int fastRefreshCount = 0;
};
#endif
-135
View File
@@ -1,135 +0,0 @@
// Wrapper class for GxEPD2_BW
// Generic signature at build-time, so that we can detect display model at run-time
// Workaround for issue of GxEPD2_BW objects not having a shared base class
// Only exposes methods which we are actually using
template <typename Driver0, typename Driver1> class GxEPD2_Multi
{
public:
void drawPixel(int16_t x, int16_t y, uint16_t color)
{
if (which == 0)
driver0->drawPixel(x, y, color);
else
driver1->drawPixel(x, y, color);
}
bool nextPage()
{
if (which == 0)
return driver0->nextPage();
else
return driver1->nextPage();
}
void hibernate()
{
if (which == 0)
driver0->hibernate();
else
driver1->hibernate();
}
void init(uint32_t serial_diag_bitrate = 0)
{
if (which == 0)
driver0->init(serial_diag_bitrate);
else
driver1->init(serial_diag_bitrate);
}
void init(uint32_t serial_diag_bitrate, bool initial, uint16_t reset_duration = 20, bool pulldown_rst_mode = false)
{
if (which == 0)
driver0->init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode);
else
driver1->init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode);
}
void setRotation(uint8_t x)
{
if (which == 0)
driver0->setRotation(x);
else
driver1->setRotation(x);
}
void setPartialWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h)
{
if (which == 0)
driver0->setPartialWindow(x, y, w, h);
else
driver1->setPartialWindow(x, y, w, h);
}
void setFullWindow()
{
if (which == 0)
driver0->setFullWindow();
else
driver1->setFullWindow();
}
int16_t width()
{
if (which == 0)
return driver0->width();
else
return driver1->width();
}
int16_t height()
{
if (which == 0)
return driver0->height();
else
return driver1->height();
}
void clearScreen(uint8_t value = 0xFF)
{
if (which == 0)
driver0->clearScreen();
else
driver1->clearScreen();
}
void endAsyncFull()
{
if (which == 0)
driver0->endAsyncFull();
else
driver1->endAsyncFull();
}
// Exposes methods of the GxEPD2_EPD object which is usually available as GxEPD2_BW::epd
class Epd2Wrapper
{
public:
bool isBusy() { return m_epd2->isBusy(); }
GxEPD2_EPD *m_epd2;
} epd2;
// Constructor
// Select driver by passing whichDriver as 0 or 1
GxEPD2_Multi(uint8_t whichDriver, int16_t cs, int16_t dc, int16_t rst, int16_t busy, SPIClass &spi)
{
assert(whichDriver == 0 || whichDriver == 1);
which = whichDriver;
LOG_DEBUG("GxEPD2_Multi driver: %d", which);
if (which == 0) {
driver0 = new GxEPD2_BW<Driver0, Driver0::HEIGHT>(Driver0(cs, dc, rst, busy, spi));
epd2.m_epd2 = &(driver0->epd2);
} else if (which == 1) {
driver1 = new GxEPD2_BW<Driver1, Driver1::HEIGHT>(Driver1(cs, dc, rst, busy, spi));
epd2.m_epd2 = &(driver1->epd2);
}
}
private:
uint8_t which;
GxEPD2_BW<Driver0, Driver0::HEIGHT> *driver0;
GxEPD2_BW<Driver1, Driver1::HEIGHT> *driver1;
};
+46 -195
View File
@@ -27,9 +27,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "configuration.h"
#include "meshUtils.h"
#if HAS_SCREEN
#include "EInkParallelDisplay.h"
#include <OLEDDisplay.h>
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "graphics/BaseUIEInkDisplay.h"
// Provided by each niche-enabled variant's nicheGraphics.h (defined once, in main.cpp TU).
extern NicheGraphics::BaseUIEInkDisplay *setupNicheGraphicsBaseUI();
#endif
#include "DisplayFormatters.h"
#include "TimeFormatters.h"
#include "draw/ClockRenderer.h"
@@ -41,7 +46,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "draw/UIRenderer.h"
#include "graphics/TFTColorRegions.h"
#include "modules/CannedMessageModule.h"
#include "security/LockdownDisplay.h"
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
@@ -120,76 +124,8 @@ static inline void prepareFrameColorRegions()
}
#endif
#ifdef MESHTASTIC_LOCKDOWN
// Static lock screen drawn in place of normal frames when
// meshtastic_security::shouldRedactDisplay() returns true. Renders centered
// "LOCKED" plus battery so the operator can see the device is alive and
// charged without leaking any node/channel/message/position content.
// Draw the LOCKED frame into the host-side framebuffer. Does NOT commit
// to the panel — the caller is responsible for calling display->display()
// once it has composited any overlays on top. Committing here would cause
// visible flicker between "just LOCKED" and "LOCKED + banner overlay" when
// the pairing-PIN special-case in updateUiFrame paints the overlay after
// this returns.
static void drawLockdownLockScreenIntoBuffer(OLEDDisplay *display)
{
display->clear();
const int w = display->getWidth();
const int h = display->getHeight();
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_LARGE);
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, "LOCKED");
display->setFont(FONT_SMALL);
char status[32] = "Connect to unlock";
if (powerStatus && powerStatus->getHasBattery()) {
int pct = powerStatus->getBatteryChargePercent();
snprintf(status, sizeof(status), "Battery %d%%", pct);
}
display->drawString(w / 2, h / 2 + 2, status);
}
// Convenience wrapper for callers that want the LOCKED frame committed
// to the panel immediately and have no overlay to compose on top.
static void drawLockdownLockScreen(OLEDDisplay *display)
{
drawLockdownLockScreenIntoBuffer(display);
display->display();
}
#endif
static inline void updateUiFrame(OLEDDisplayUi *ui)
{
#ifdef MESHTASTIC_LOCKDOWN
if (meshtastic_security::shouldRedactDisplay() && screen != nullptr) {
OLEDDisplay *display = screen->getDisplayDevice();
// Paint LOCKED into the framebuffer WITHOUT committing. We commit
// exactly once at the bottom — after any overlay has been composed
// on top — so the panel never visibly transitions from "just LOCKED"
// to "LOCKED + overlay" mid-frame. Committing twice per cycle was
// the source of the H13 flicker.
drawLockdownLockScreenIntoBuffer(display);
// Special-case the BLE pairing PIN banner. The PIN is needed to
// complete first-pair against a locked device, but the lockdown
// short-circuit would otherwise hide the PIN entirely. The PIN is
// a per-attempt ephemeral pair-handshake artifact, not operator
// content, so compositing it over the LOCKED frame is safe.
//
// Calling ui->update() here would be wrong: it redraws the current
// carousel frame (the dashboard) into the framebuffer before the
// overlay paints, leaving operator content visible underneath the
// banner. Instead we invoke the banner overlay callback directly,
// which paints only the banner box on top of the LOCKED pixels we
// already have in the framebuffer.
if (NotificationRenderer::current_notification_type == notificationTypeEnum::pairing_pin) {
NotificationRenderer::drawBannercallback(display, ui->getUiState());
}
display->display();
return;
}
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
@@ -233,16 +169,6 @@ static inline float wrapHeading360(float heading)
return heading;
}
static inline float wrapDelta180(float delta)
{
if (delta > 180.0f) {
delta -= 360.0f;
} else if (delta < -180.0f) {
delta += 360.0f;
}
return delta;
}
void Screen::setHeading(float heading)
{
const float wrappedHeading = wrapHeading360(heading);
@@ -254,30 +180,37 @@ void Screen::setHeading(float heading)
}
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
float delta = wrapDelta180(wrappedHeading - compassHeading);
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) {
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);
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);
}
// ==============================
@@ -515,14 +448,9 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR)
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) && !defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
dispdev = new EInkDynamicDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkParallelDisplay(EPD_WIDTH, EPD_HEIGHT, EInkParallelDisplay::EPD_ROT_PORTRAIT);
#elif defined(USE_EINK) && defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
// NicheGraphics-backed BaseUI E-Ink path. Variant provides setupNicheGraphicsBaseUI() in its nicheGraphics.h.
dispdev = setupNicheGraphicsBaseUI();
#elif defined(USE_ST7567)
dispdev = new ST7567Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -655,21 +583,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
setScreensaverFrames(einkScreensaver);
#endif
#ifdef MESHTASTIC_LOCKDOWN
// M19: before turning the panel off, paint a safe frame into the
// OLED's GDDRAM. The panel retains whatever was last written even
// while powered down, so when displayOn() is called later the
// screen would otherwise flash the previous frame's content for
// 16-50 ms before the next ui->update() lands. Painting the
// LOCKED frame now ensures the only thing the operator (or
// someone over their shoulder) can see on wake is the redacted
// view. Gated on lockdown — non-lockdown builds keep the
// previous frame as a UX cue that the display is just dimmed.
// dispdev is dereferenced unguarded throughout this file (incl.
// displayOff() just below), so no null check here.
drawLockdownLockScreen(dispdev);
#endif
#ifdef PIN_EINK_EN
digitalWrite(PIN_EINK_EN, LOW);
#elif defined(PCA_PIN_EINK_EN)
@@ -789,27 +702,6 @@ void Screen::setup()
#endif
LOG_INFO("Applied screen brightness: %d", brightness);
#if defined(MESHTASTIC_LOCKDOWN) && defined(USE_EINK)
// M20: e-ink panels physically retain the last-rendered image without
// power, so a power-cycled lockdown handheld would keep showing
// operator-identifying content (position, messages, node info) until
// the firmware's first natural refresh — which on e-ink can be seconds
// into boot. Force a full refresh to the LOCKED frame here, immediately
// after the display is initialised and before any other rendering, so
// the persistent pixels are wiped to the redacted view before an
// observer can see them.
if (meshtastic_security::shouldRedactDisplay()) {
drawLockdownLockScreen(dispdev);
#if defined(USE_EINK_PARALLELDISPLAY)
// Parallel-display variants drive refresh through a different path;
// a bare drawLockdownLockScreen above lands the frame into the
// panel buffer and the next ui->update() commits it as normal.
#else
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
#endif
}
#endif
// Set custom overlay callbacks
static OverlayCallback overlays[] = {
graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame
@@ -917,16 +809,10 @@ void Screen::setOn(bool on, FrameCallback einkScreensaver)
if (cardKbI2cImpl)
cardKbI2cImpl->toggleBacklight(on);
#endif
if (!on) {
#ifdef MESHTASTIC_LOCKDOWN
// Screen powering off (idle timeout, shutdown, deep sleep) latches
// the screen-lock. Next time the display wakes it shows the LOCKED
// frame until a client authenticates with the passphrase.
meshtastic_security::lockScreen();
#endif
if (!on)
// We handle off commands immediately, because they might be called because the CPU is shutting down
handleSetOn(false, einkScreensaver);
} else
else
enqueueCmd(ScreenCmd{.cmd = Cmd::SET_ON});
}
@@ -962,10 +848,8 @@ void Screen::forceDisplay(bool forceUiUpdate)
}
// Tell EInk class to update the display
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay();
#elif defined(USE_EINK)
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
static_cast<NicheGraphics::BaseUIEInkDisplay *>(dispdev)->forceDisplay();
#endif
#else
// No delay between UI frame rendering
@@ -1033,17 +917,7 @@ int32_t Screen::runOnce()
#endif
#ifndef DISABLE_WELCOME_UNSET
bool suppressRegionOnboard = false;
#ifdef MESHTASTIC_LOCKDOWN
// While lockdown is active and storage is still locked, config.lora.region
// is a deliberate UNSET placeholder — the real region lives in encrypted
// storage and is restored on unlock (see NodeDB's locked-boot path). Don't
// pop the region picker over the lock screen: it would trap input, and the
// operator can't set a region until they unlock anyway.
suppressRegionOnboard = meshtastic_security::shouldRedactDisplay();
#endif
if (!suppressRegionOnboard && !NotificationRenderer::isOverlayBannerShowing() &&
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if defined(OLED_TINY)
menuHandler::LoraRegionPicker();
#else
@@ -1162,12 +1036,7 @@ int32_t Screen::runOnce()
NotificationRenderer::current_notification_type != notificationTypeEnum::text_input &&
!Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) {
// If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead
// Carousel is potentially a major source of E-Ink display wear
#if !defined(EINK_BACKGROUND_USES_FAST)
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC);
#endif
// Carousel rotations let BaseUIEInkDisplay's DisplayHealth debt model decide FAST vs FULL.
LOG_DEBUG("LastScreenTransition exceeded %ums transition to next frame", (millis() - lastScreenTransition));
handleOnPress();
}
@@ -1201,11 +1070,8 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
static FrameCallback screensaverFrame;
static OverlayCallback screensaverOverlay;
#if defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY)
// Join (await) a currently running async refresh, then run the post-update code.
// Avoid skipping of screensaver frame. Would otherwise be handled by NotifiedWorkerThread.
// Join (await) any currently running async refresh before drawing the screensaver frame.
EINK_JOIN_ASYNCREFRESH(dispdev);
#endif
// If: one-off screensaver frame passed as argument. Handles doDeepSleep()
if (einkScreensaver != NULL) {
@@ -1228,23 +1094,17 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay(0);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY)
// Old EInkDisplay class
static_cast<EInkDisplay *>(dispdev)->forceDisplay(0); // Screen::forceDisplay(), but override rate-limit
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
static_cast<NicheGraphics::BaseUIEInkDisplay *>(dispdev)->forceDisplay(0);
#endif
// Prepare now for next frame, shown when display wakes
ui->setOverlays(NULL, 0); // Clear overlay
setFrames(FOCUS_PRESERVE); // Return to normal display updates, showing same frame as before screensaver, ideally
// Pick a refresh method, for when display wakes
#ifdef EINK_HASQUIRK_GHOSTING
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused"
#else
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
#endif
// Pick a refresh method for when the display wakes. RESPONSIVE = FAST; DisplayHealth
// will promote to FULL on its own schedule if FAST debt has built up.
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE);
}
#endif
@@ -1759,15 +1619,6 @@ void Screen::handleStartFirmwareUpdateScreen()
void Screen::blink()
{
#ifdef MESHTASTIC_LOCKDOWN
// L4: defensive guard. blink() paints arbitrary geometry, not node
// data, so it doesn't actually leak today. But it bypasses the normal
// ui->update() path that the lockdown short-circuit gates, so any
// future change that puts content into blink would silently leak past
// redaction. Refuse to draw when the redaction latch is set.
if (meshtastic_security::shouldRedactDisplay())
return;
#endif
setFastFramerate();
uint8_t count = 10;
dispdev->setBrightness(254);
+2 -17
View File
@@ -12,21 +12,7 @@
#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
namespace graphics
{
enum notificationTypeEnum {
none,
text_banner,
selection_picker,
node_picker,
number_picker,
hex_picker,
text_input,
// BLE pairing PIN banner. Treated specially by the lockdown short-circuit
// in Screen.cpp: the PIN is ephemeral (regenerated per pair attempt) and
// not a real secret, so we allow ui->update() to composite it over the
// LOCKED frame. Without this, a first-pair on a locked device cannot
// complete because the PIN never renders.
pairing_pin,
};
enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input };
struct BannerOverlayOptions {
const char *message;
@@ -101,8 +87,7 @@ class Screen
#include <AutoOLEDWire.h>
#endif
#include "EInkDisplay2.h"
#include "EInkDynamicDisplay.h"
#include "BaseUIEInkDisplay.h"
#include "PointStruct.h"
#include "TFTDisplay.h"
#include "TypedQueue.h"
-4
View File
@@ -578,11 +578,7 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
#endif
display->setColor(BLACK);
#if GRAPHICS_TFT_COLORING_ENABLED
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
#else
display->fillRect(0, footerY, connection_icon_width + 1, footerH);
#endif
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
const int bytesPerRow = (connection_icon_width + 7) / 8;
+4 -2
View File
@@ -1533,7 +1533,8 @@ bool TFTDisplay::hasTouch(void)
{
#ifdef RAK14014
return true;
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
!defined(HELTEC_MESH_NODE_T1)
return tft->touch() != nullptr;
#else
return false;
@@ -1552,7 +1553,8 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
} else {
return false;
}
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
!defined(HELTEC_MESH_NODE_T1)
return tft->getTouch(x, y);
#else
return false;
+2 -6
View File
@@ -183,13 +183,9 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
static float segmentHeight = SEGMENT_HEIGHT * 0.75f;
if (!scaleInitialized) {
#ifdef DISPLAY_FORCE_SMALL_FONTS
float screenwidth_target_ratio = 0.70f; // Target 70% of display width (adjustable)
#else
float screenwidth_target_ratio = 0.80f; // Target 80% of display width (adjustable)
#endif
float max_scale = 3.5f; // Safety limit to avoid runaway scaling
float step = 0.05f; // Step increment per iteration
float max_scale = 3.5f; // Safety limit to avoid runaway scaling
float step = 0.05f; // Step increment per iteration
float target_width = display->getWidth() * screenwidth_target_ratio;
float target_height =
+31 -118
View File
@@ -126,7 +126,6 @@ void launchReplyForMessage(const StoredMessage &message, bool freetext)
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
uint32_t menuHandler::pickedNodeNum = 0;
meshtastic_Config_LoRaConfig_RegionCode menuHandler::pendingRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
bool test_enabled = false;
uint8_t test_count = 0;
@@ -175,48 +174,6 @@ void menuHandler::OnboardMessage()
screen->showOverlayBanner(bannerOptions);
}
static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam)
{
config.lora.region = region;
config.lora.channel_num = 0; // Reset to default channel
// Reconcile the preset with the explicitly chosen region: a preset locked to another
// region would leave config.lora invalid until applyModemConfig() repairs it with
// error/critical-error side effects — or, for the swappable EU trio, the clamp would
// flip the region right back. The user picked the region, so the preset follows it.
const RegionInfo *newRegion = getRegion(region);
if (config.lora.use_preset && !newRegion->supportsPreset(config.lora.modem_preset)) {
LOG_INFO("Preset %s not available in %s, using default %s",
DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), newRegion->name,
DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true));
config.lora.modem_preset = newRegion->getDefaultPreset();
}
if (isHam && adminModule) {
meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero;
strncpy(hamParams.call_sign, "N0CALL", sizeof(hamParams.call_sign) - 1);
strncpy(hamParams.short_name, "N0CL", sizeof(hamParams.short_name));
hamParams.tx_power = config.lora.tx_power;
hamParams.frequency = config.lora.override_frequency;
adminModule->handleSetHamMode(hamParams);
}
auto changes = SEGMENT_CONFIG;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
initRegion();
if (getEffectiveDutyCycle() < 100) {
config.lora.ignore_mqtt = true;
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG;
}
service->reloadConfig(changes);
}
void menuHandler::LoraRegionPicker(uint32_t duration)
{
static const LoraRegionOption regionOptions[] = {
@@ -275,34 +232,37 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
return;
}
// Guard: without a reboot, reconfigure() applies the region directly, so reject
// regions this node can't use up front: unrecognized codes, licensed-only regions,
// and radio hardware mismatches (2.4 GHz vs sub-GHz) — the same checks the admin
// set-config path applies, but side-effect-free: ignoring a menu selection should
// not record a critical error or notify clients. getRadio() used to catch hardware
// mismatches post-reboot only.
auto candidateLora = config.lora;
candidateLora.region = selectedRegion;
char regionErr[160];
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
LOG_WARN("Ignoring region selection: %s", regionErr);
// 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;
}
bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
if (hamMode) {
LOG_INFO("User chose an amateur radio mode region");
pendingRegion = selectedRegion;
menuQueue = HamModeConfirm;
screen->runNow();
} else if (owner.is_licensed) {
LOG_INFO("Licensed user chose a non-ham region; prompting to revert licensed mode");
pendingRegion = selectedRegion;
menuQueue = LicensedToNormalConfirm;
screen->runNow();
} else {
applyLoraRegion(selectedRegion, false);
config.lora.region = selectedRegion;
auto changes = SEGMENT_CONFIG;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
config.lora.tx_enabled = true;
initRegion();
if (getEffectiveDutyCycle() < 100) {
config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
// Default broker is in use, so subscribe to the appropriate MQTT root topic for this region
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG;
}
service->reloadConfig(changes);
});
bannerOptions.durationMs = duration;
@@ -319,38 +279,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::hamModeConfirmMenu()
{
static const char *confirmOptions[] = {"No", "Yes"};
BannerOverlayOptions confirmBanner;
confirmBanner.message = "I confirm I am a\nlicensed amateur\nradio operator";
confirmBanner.optionsArrayPtr = confirmOptions;
confirmBanner.optionsCount = 2;
confirmBanner.bannerCallback = [](int selected) {
if (selected == 1)
applyLoraRegion(pendingRegion, true);
};
screen->showOverlayBanner(confirmBanner);
}
void menuHandler::licensedToNormalConfirmMenu()
{
static const char *confirmOptions[] = {"Keep licensed", "Revert to Normal"};
BannerOverlayOptions confirmBanner;
confirmBanner.message = "Revert licensed\nmode? This will\nre-enable encryption.";
confirmBanner.optionsArrayPtr = confirmOptions;
confirmBanner.optionsCount = 2;
confirmBanner.bannerCallback = [](int selected) {
if (selected == 1) {
owner.is_licensed = false;
config.lora.override_duty_cycle = false;
service->reloadOwner(false);
}
applyLoraRegion(pendingRegion, false);
};
screen->showOverlayBanner(confirmBanner);
}
void menuHandler::deviceRolePicker()
{
static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"};
@@ -1572,7 +1500,6 @@ void menuHandler::manageNodeMenu()
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
} else {
LOG_INFO("Adding node %08X to favorites", menuHandler::pickedNodeNum);
// set_favorite() already logs PROTECTED_CAP_WARN_FMT on a cap refusal; don't double-log here.
nodeDB->set_favorite(true, menuHandler::pickedNodeNum);
}
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
@@ -1616,23 +1543,15 @@ void menuHandler::manageNodeMenu()
return;
}
bool changed = false;
if (nodeInfoLiteIsIgnored(n)) {
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
changed = true;
} else if (nodeDB->setProtectedFlag(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
changed = true;
} else {
LOG_WARN(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", menuHandler::pickedNodeNum, MAX_NUM_NODES - 2);
}
// Only persist/notify when the ignore bit actually moved; a cap
// refusal changed nothing and shouldn't trigger a prefs save.
if (changed) {
nodeDB->notifyObservers(true);
nodeDB->saveToDisk();
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
}
nodeDB->notifyObservers(true);
nodeDB->saveToDisk();
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
return;
}
@@ -2903,12 +2822,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case ThemeMenu:
themeMenu();
break;
case HamModeConfirm:
hamModeConfirmMenu();
break;
case LicensedToNormalConfirm:
licensedToNormalConfirmMenu();
break;
}
menuQueue = MenuNone;
}
+1 -6
View File
@@ -55,13 +55,10 @@ class menuHandler
FrameToggles,
DisplayUnits,
MessageBubblesMenu,
ThemeMenu,
HamModeConfirm,
LicensedToNormalConfirm
ThemeMenu
};
static screenMenus menuQueue;
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
static meshtastic_Config_LoRaConfig_RegionCode pendingRegion;
static void OnboardMessage();
static void LoraRegionPicker(uint32_t duration = 30000);
@@ -114,8 +111,6 @@ class menuHandler
static void messageBubblesMenu();
static void themeMenu();
static void textMessageMenu();
static void hamModeConfirmMenu();
static void licensedToNormalConfirmMenu();
private:
static void saveUIConfig();
-117
View File
@@ -66,15 +66,6 @@ uint32_t pow_of_10(uint32_t n)
return ret;
}
uint64_t pow_of_16(uint32_t n)
{
uint64_t ret = 1;
for (uint32_t i = 0; i < n; i++) {
ret *= 16ULL;
}
return ret;
}
char graphics::NotificationRenderer::alertBannerLines[MAX_LINES + 1][64] = {};
uint8_t graphics::NotificationRenderer::alertBannerLineCount = 0;
graphics::NotificationRenderer::BannerFont graphics::NotificationRenderer::alertBannerLineFonts[MAX_LINES + 1] = {};
@@ -260,12 +251,6 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
break;
case notificationTypeEnum::text_banner:
case notificationTypeEnum::selection_picker:
case notificationTypeEnum::pairing_pin:
// pairing_pin is rendered the same as text_banner — it's just a
// text banner. The split type exists only so the lockdown UI
// short-circuit in Screen.cpp can recognise the BLE pair-PIN
// banner as the one safe banner to composite over the LOCKED
// frame.
drawAlertBannerOverlay(display, state);
break;
case notificationTypeEnum::node_picker:
@@ -274,9 +259,6 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
case notificationTypeEnum::number_picker:
drawNumberPicker(display, state);
break;
case notificationTypeEnum::hex_picker:
drawHexPicker(display, state);
break;
}
}
@@ -363,105 +345,6 @@ void NotificationRenderer::drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiS
drawNotificationBox(display, state, linePointers, totalLines, 0);
}
void NotificationRenderer::drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state)
{
const char *lineStarts[MAX_LINES + 1] = {0};
uint16_t lineCount = 0;
// Parse lines
char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
lineStarts[lineCount] = alertBannerMessage;
// Find lines
while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n');
if (lineStarts[lineCount + 1][0] == '\n')
lineStarts[lineCount + 1] += 1;
lineCount++;
}
// modulo to extract
uint8_t this_digit = (currentNumber % (pow_of_16(numDigits - curSelected))) / (pow_of_16(numDigits - curSelected - 1));
// Handle input
if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS ||
inEvent.inputEvent == INPUT_BROKER_UP_LONG) {
if (this_digit == 15) {
currentNumber -= 15 * (pow_of_16(numDigits - curSelected - 1));
} else {
currentNumber += (pow_of_16(numDigits - curSelected - 1));
}
} else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS ||
inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) {
if (this_digit == 0) {
currentNumber += 15 * (pow_of_16(numDigits - curSelected - 1));
} else {
currentNumber -= (pow_of_16(numDigits - curSelected - 1));
}
} else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) {
if (inEvent.kbchar > 47 && inEvent.kbchar < 58) { // have a digit
currentNumber -= this_digit * (pow_of_16(numDigits - curSelected - 1));
currentNumber += (inEvent.kbchar - 48) * (pow_of_16(numDigits - curSelected - 1));
curSelected++;
}
} else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) {
curSelected++;
} else if (inEvent.inputEvent == INPUT_BROKER_LEFT) {
curSelected--;
} else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) &&
alertBannerUntil != 0) {
resetBanner();
return;
}
if (curSelected == static_cast<int8_t>(numDigits)) {
alertBannerCallback(currentNumber);
resetBanner();
return;
}
inEvent.inputEvent = INPUT_BROKER_NONE;
if (alertBannerMessage[0] == '\0')
return;
uint16_t totalLines = lineCount + 2;
const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation
// copy the linestarts to display to the linePointers holder
for (uint16_t i = 0; i < lineCount; i++) {
linePointers[i] = lineStarts[i];
}
std::string digits = " ";
std::string arrowPointer = " ";
for (uint16_t i = 0; i < numDigits; i++) {
// Modulo minus modulo to return just the current number
uint8_t digitValue = (currentNumber % (pow_of_16(numDigits - i))) / (pow_of_16(numDigits - i - 1));
if (digitValue < 10) {
digits += std::to_string(digitValue) + " ";
} else if (digitValue == 10) {
digits += "A ";
} else if (digitValue == 11) {
digits += "B ";
} else if (digitValue == 12) {
digits += "C ";
} else if (digitValue == 13) {
digits += "D ";
} else if (digitValue == 14) {
digits += "E ";
} else if (digitValue == 15) {
digits += "F ";
}
if (curSelected == i) {
arrowPointer += "^ ";
} else {
arrowPointer += "_ ";
}
}
linePointers[lineCount++] = digits.c_str();
linePointers[lineCount++] = arrowPointer.c_str();
drawNotificationBox(display, state, linePointers, totalLines, 0);
}
void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state)
{
static uint32_t selectedNodenum = 0;
-1
View File
@@ -42,7 +42,6 @@ class NotificationRenderer
static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1],
+4 -11
View File
@@ -79,12 +79,10 @@ static inline void transformNeedlePoint(float localX, float localY, float sinHea
outY = static_cast<int16_t>(y);
}
#if GRAPHICS_TFT_COLORING_ENABLED
static float getCompassRingAngleOffset(float heading)
{
return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f;
}
#endif
static inline StandardCompassNeedlePoints computeStandardCompassNeedlePoints(int16_t compassX, int16_t compassY,
uint16_t compassDiam, float headingRadian,
@@ -1144,16 +1142,11 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
bool origBold = config.display.heading_bold;
config.display.heading_bold = false;
if (!config.lora.tx_enabled) {
const char *txdisabled = "Transmit Disabled";
display->drawString(x, getTextPositions(display)[line], txdisabled);
// Display Region and Channel Utilization
if (currentResolution == ScreenResolution::UltraLow) {
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
} else {
// Display Region and Channel Utilization
if (currentResolution == ScreenResolution::UltraLow) {
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
} else {
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
}
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
}
char uptimeStr[32] = "";
if (currentResolution != ScreenResolution::UltraLow) {
+132
View File
@@ -0,0 +1,132 @@
#include "./EInkParallel.h"
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
#include "FastEPD.h"
using namespace NicheGraphics::Drivers;
EInkParallel::EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock)
: EInk(width, height, (UpdateTypes)(FULL | FAST)), panelType(panelType), panelClock(panelClock)
{
}
EInkParallel::~EInkParallel()
{
if (asyncRunning.load()) {
for (int i = 0; i < 50 && asyncRunning.load(); ++i)
delay(50);
if (asyncTaskHandle) {
vTaskDelete(asyncTaskHandle);
asyncTaskHandle = nullptr;
}
}
delete epaper;
}
void EInkParallel::begin(SPIClass *, uint8_t, uint8_t, uint8_t, uint8_t)
{
// Parallel panels don't use the SPI args; FastEPD owns the bus.
if (!epaper) {
epaper = new FASTEPD;
epaper->initPanel((int)panelType, panelClock);
postPanelInit();
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true);
}
}
void EInkParallel::update(uint8_t *imageData, UpdateTypes type)
{
if (!epaper)
return;
pendingType = type;
copyImageInverted(imageData);
if (type == FULL) {
// Pick CLEAR_SLOW periodically to clear ghosting.
const int clearMode = (fastRefreshCount >= FULL_SLOW_PERIOD) ? CLEAR_SLOW : CLEAR_FAST;
fastRefreshCount = 0;
if (!asyncRunning.load()) {
asyncRunning.store(true);
BaseType_t rc =
xTaskCreatePinnedToCore(asyncFullTask, "epd_full", 4096 / sizeof(StackType_t), this, 2, &asyncTaskHandle,
#if CONFIG_FREERTOS_UNICORE
0
#else
1
#endif
);
if (rc != pdPASS) {
LOG_WARN("Async full failed; running blocking");
epaper->fullUpdate(clearMode, false);
epaper->backupPlane();
asyncRunning.store(false);
asyncTaskHandle = nullptr;
return; // synchronous: nothing to poll
}
// Begin polling for completion.
beginPolling(100, 1500);
}
} else {
// FAST: synchronous partial / clipped fullUpdate. Block briefly here.
epaper->fullUpdate(CLEAR_FAST, false);
epaper->backupPlane();
fastRefreshCount++;
// No polling needed; isUpdateDone() will report done immediately.
beginPolling(10, 0);
}
}
void EInkParallel::asyncFullTask(void *param)
{
auto *self = static_cast<EInkParallel *>(param);
if (!self) {
vTaskDelete(nullptr);
return;
}
self->epaper->fullUpdate(CLEAR_FAST, false);
self->epaper->backupPlane();
self->asyncRunning.store(false);
self->asyncTaskHandle = nullptr;
vTaskDelete(nullptr);
}
bool EInkParallel::isUpdateDone()
{
return !asyncRunning.load();
}
void EInkParallel::finalizeUpdate()
{
pendingType = UpdateTypes::UNSPECIFIED;
}
// Convert a niche-format buffer (row-major, MSB-left, 1=WHITE) into FastEPD's currentBuffer
// (row-major, MSB-left, 1=BLACK). Polarity inversion only.
void EInkParallel::copyImageInverted(const uint8_t *src)
{
uint8_t *dst = epaper->currentBuffer();
if (!dst || !src)
return;
const uint16_t rowBytes = ((width - 1) / 8) + 1;
const uint32_t total = rowBytes * height;
// Mask off bits beyond the panel width in the trailing byte of each row.
const uint8_t trailingMask = (uint8_t)(0xFFu << ((rowBytes * 8) - width));
for (uint16_t y = 0; y < height; y++) {
const uint32_t base = y * rowBytes;
for (uint16_t b = 0; b < rowBytes - 1; b++) {
dst[base + b] = ~src[base + b];
}
dst[base + rowBytes - 1] = (~src[base + rowBytes - 1]) & trailingMask;
}
(void)total;
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
+69
View File
@@ -0,0 +1,69 @@
/*
Parallel-EPD niche driver, backed by FastEPD.
Used for boards with an 8-bit parallel EPD interface (e.g. LILYGO T5 S3 ePaper).
The base class signature passes SPI parameters; this driver ignores them and uses FastEPD
to drive the parallel bus directly.
Gated on NICHE_HAS_FASTEPD because FastEPD is a heavy dependency that only parallel-EPD
variants want pulled in. Variants opt in by defining NICHE_HAS_FASTEPD in their platformio.ini
and adding the FastEPD library to lib_deps.
*/
#pragma once
#include "configuration.h"
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
#include "./EInk.h"
#include <atomic>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
class FASTEPD;
namespace NicheGraphics::Drivers
{
class EInkParallel : public EInk
{
public:
EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock = 28000000);
~EInkParallel();
// SPI parameters are unused for parallel panels.
void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override;
void update(uint8_t *imageData, UpdateTypes type) override;
FASTEPD *fastEpd() { return epaper; }
protected:
bool isUpdateDone() override;
void finalizeUpdate() override;
// Hook for boards that need to bring up GPIO expanders / power pins after FastEPD::initPanel.
virtual void postPanelInit() {}
private:
void copyImageInverted(const uint8_t *src);
static void asyncFullTask(void *param);
FASTEPD *epaper = nullptr;
uint32_t panelType;
uint32_t panelClock;
UpdateTypes pendingType = UpdateTypes::UNSPECIFIED;
std::atomic<bool> asyncRunning{false};
TaskHandle_t asyncTaskHandle = nullptr;
uint8_t fastRefreshCount = 0;
static constexpr uint8_t FULL_SLOW_PERIOD = 100;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
+49
View File
@@ -0,0 +1,49 @@
#include "./GDEH0122T61.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEH0122T61::configScanning()
{
sendCommand(0x01);
sendData(0xAF); // Scan until gate 175 (176px vertical resolution, low byte)
sendData(0x00); // high byte
sendData(0x00);
}
void GDEH0122T61::configWaveform()
{
sendCommand(0x3C);
sendData(0x05);
sendCommand(0x18);
sendData(0x80);
}
void GDEH0122T61::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEH0122T61::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 250);
case FULL:
default:
return beginPolling(100, 1500);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEH0122T61
- Manufacturer: Good Display
- Size: 1.22 inch
- Resolution: 192px x 176px
- Controller IC: SSD1681 (operating in a sub-200x200 window)
Used by: t-echo-lite.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEH0122T61 : public SSD16XX
{
private:
static constexpr uint32_t width = 192;
static constexpr uint32_t height = 176;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEH0122T61() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+57
View File
@@ -0,0 +1,57 @@
#include "./GDEQ031T10.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEQ031T10::configScanning()
{
sendCommand(0x01);
sendData(0x3F); // 319, low byte
sendData(0x01); // 319, high byte
sendData(0x00);
}
void GDEQ031T10::configWaveform()
{
sendCommand(0x3C);
sendData(0x01);
sendCommand(0x18);
sendData(0x80);
}
void GDEQ031T10::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x21);
sendData(0x00);
sendData(0x00);
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x21);
sendData(0x40);
sendData(0x00);
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEQ031T10::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 400);
case FULL:
default:
return beginPolling(100, 2500);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEQ031T10
- Manufacturer: Good Display
- Size: 3.1 inch
- Resolution: 240px x 320px
- Controller IC: SSD1677 (SSD16XX-family, larger memory range)
Used by: t-deck-pro.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEQ031T10 : public SSD16XX
{
private:
static constexpr uint32_t width = 240;
static constexpr uint32_t height = 320;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEQ031T10() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+49
View File
@@ -0,0 +1,49 @@
#include "./GDEY029T94.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEY029T94::configScanning()
{
sendCommand(0x01);
sendData(0x27); // 295, low byte
sendData(0x01); // 295, high byte
sendData(0x00);
}
void GDEY029T94::configWaveform()
{
sendCommand(0x3C);
sendData(0x05);
sendCommand(0x18);
sendData(0x80);
}
void GDEY029T94::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEY029T94::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 300);
case FULL:
default:
return beginPolling(100, 2000);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEY029T94 (also sold as GDEY029T94-V2)
- Manufacturer: Good Display
- Size: 2.9 inch
- Resolution: 128px x 296px
- Controller IC: SSD1680
Used by: esp32-s3-pico, crowpanel-esp32s3-2-epaper.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEY029T94 : public SSD16XX
{
private:
static constexpr uint32_t width = 128;
static constexpr uint32_t height = 296;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEY029T94() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+49
View File
@@ -0,0 +1,49 @@
#include "./GDEY042T81.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEY042T81::configWaveform()
{
sendCommand(0x3C);
sendData(0x01);
sendCommand(0x18);
sendData(0x80);
}
void GDEY042T81::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x21);
sendData(0x00);
sendData(0x00);
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x21);
sendData(0x40);
sendData(0x00);
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEY042T81::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 1000);
case FULL:
default:
return beginPolling(100, 3500);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+42
View File
@@ -0,0 +1,42 @@
/*
E-Ink display driver
- GDEY042T81
- Manufacturer: Good Display
- Size: 4.2 inch
- Resolution: 400px x 300px
- Controller IC: SSD1683
Used by: ME25LS01-4Y10TD_e-ink.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEY042T81 : public SSD16XX
{
private:
static constexpr uint32_t width = 400;
static constexpr uint32_t height = 300;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEY042T81() : SSD16XX(width, height, supported) {}
protected:
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+57
View File
@@ -0,0 +1,57 @@
#include "./GDEY0579T93.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEY0579T93::configScanning()
{
sendCommand(0x01);
sendData(0x0F); // 271, low byte
sendData(0x01); // 271, high byte
sendData(0x00);
}
void GDEY0579T93::configWaveform()
{
sendCommand(0x3C);
sendData(0x01);
sendCommand(0x18);
sendData(0x80);
}
void GDEY0579T93::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x21);
sendData(0x00);
sendData(0x00);
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x21);
sendData(0x40);
sendData(0x00);
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEY0579T93::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(100, 2000);
case FULL:
default:
return beginPolling(150, 5000);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEY0579T93
- Manufacturer: Good Display
- Size: 5.79 inch
- Resolution: 792px x 272px
- Controller IC: SSD1683 (extended memory range)
Used by: crowpanel-esp32s3-5-epaper.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEY0579T93 : public SSD16XX
{
private:
static constexpr uint32_t width = 792;
static constexpr uint32_t height = 272;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEY0579T93() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS

Some files were not shown because too many files have changed in this diff Show More