Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e25016cbbc | ||
|
|
76bb9dbf11 | ||
|
|
29572e144e | ||
|
|
f2f23f8978 | ||
|
|
9358b2c549 | ||
|
|
4f0e2dde98 | ||
|
|
68af6b277c | ||
|
|
79a7dcc46c | ||
|
|
b311e01ce0 | ||
|
|
ae3493a00c | ||
|
|
1cf6d7648f | ||
|
|
5ffd30c4a8 | ||
|
|
358e4e2fcd | ||
|
|
7424631a27 | ||
|
|
2291b672c4 | ||
|
|
a944e1c908 | ||
|
|
e4f4d1f9e7 | ||
|
|
43d485dd76 | ||
|
|
d878c81ce8 | ||
|
|
8a0c7592cc | ||
|
|
882ca0a216 | ||
|
|
5d1c4f15b7 | ||
|
|
745b53698a | ||
|
|
b938b63e8a | ||
|
|
b76e5e6ba4 | ||
|
|
bbcc35e209 | ||
|
|
bede05356d | ||
|
|
031e73bbe6 | ||
|
|
9ea1f0065a | ||
|
|
8267bb22bd | ||
|
|
07a87a8254 | ||
|
|
eb719f6fca | ||
|
|
02081dc85d | ||
|
|
ed52e3019d | ||
|
|
c2bcec93d0 | ||
|
|
8bb5364d8c | ||
|
|
83c7e4ede3 | ||
|
|
a14f7afe87 | ||
|
|
1490daa7ca | ||
|
|
a4001d71d5 | ||
|
|
6da9f5f20e | ||
|
|
ab882c5619 | ||
|
|
2541db2bef | ||
|
|
f875518b28 | ||
|
|
334ad9b313 | ||
|
|
0953706e9e | ||
|
|
309d51a3e8 | ||
|
|
93f87c57b9 | ||
|
|
94ef2ae451 | ||
|
|
abef0d85a2 | ||
|
|
6b3f975ba5 | ||
|
|
e028663658 | ||
|
|
bf68b9e597 | ||
|
|
a9b98f47e9 | ||
|
|
90a3ac5938 | ||
|
|
38f15db1d0 | ||
|
|
da821ec663 | ||
|
|
124bffad84 | ||
|
|
f98abe00f3 | ||
|
|
56a33a07f7 | ||
|
|
ce80433e43 | ||
|
|
3d98622b96 | ||
|
|
d3691258d3 | ||
|
|
360c54f1f9 | ||
|
|
8c4900a52f | ||
|
|
bfb833982e | ||
|
|
1410f170f9 | ||
|
|
14e998e6c3 | ||
|
|
57f678240d | ||
|
|
99df0a6fe9 | ||
|
|
ce7444c1a1 | ||
|
|
e3ae0a6ab5 | ||
|
|
733430ed45 | ||
|
|
cd2466692f | ||
|
|
4b424f0234 | ||
|
|
a212d2e84f | ||
|
|
5154e81d0b | ||
|
|
5c1b6b2a23 | ||
|
|
de345939af | ||
|
|
ef51c7ec11 | ||
|
|
3e873c51b7 | ||
|
|
f86cb7781e | ||
|
|
fe23dcfa3a | ||
|
|
2d6f2ba1a4 | ||
|
|
5d55353939 | ||
|
|
c3a46d4d85 | ||
|
|
0e0e17928b | ||
|
|
a522973fe7 | ||
|
|
cbfa8d7ffd |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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)..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
|
||||
# trunk-ignore(hadolint/DL3008): apt packages are not pinned.
|
||||
# trunk-ignore(terrascan/AC_DOCKER_0002): apt packages are not pinned.
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
cmake git zip libgpiod-dev libbluetooth-dev libi2c-dev \
|
||||
cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \
|
||||
libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \
|
||||
libusb-1.0-0-dev libssl-dev pkg-config libsqlite3-dev libsdl2-dev && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
||||
|
||||
@@ -31,7 +31,7 @@ cmake --install "$WORK/ulfius/$SANITIZER" --prefix /usr
|
||||
cd "$SRC/firmware"
|
||||
|
||||
PLATFORMIO_EXTRA_SCRIPTS=$(echo -e "pre:.clusterfuzzlite/platformio-clusterfuzzlite-pre.py\npost:.clusterfuzzlite/platformio-clusterfuzzlite-post.py")
|
||||
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp bluez --silence-errors)
|
||||
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp jsoncpp bluez --silence-errors)
|
||||
export PLATFORMIO_EXTRA_SCRIPTS
|
||||
export STATIC_LIBS
|
||||
export PLATFORMIO_WORKSPACE_DIR="$WORK/pio/$SANITIZER"
|
||||
|
||||
@@ -16,6 +16,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
libssl-dev \
|
||||
libulfius-dev \
|
||||
libyaml-cpp-dev \
|
||||
libjsoncpp-dev \
|
||||
pipx \
|
||||
pkg-config \
|
||||
python3 \
|
||||
|
||||
@@ -13,7 +13,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get -y update --fix-missing
|
||||
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev lsb-release
|
||||
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -11,4 +11,4 @@ runs:
|
||||
- name: Install libs needed for native build
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
|
||||
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
|
||||
|
||||
@@ -191,7 +191,24 @@ 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, `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.
|
||||
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.
|
||||
|
||||
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
|
||||
|
||||
@@ -283,6 +300,15 @@ 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
|
||||
@@ -609,20 +635,35 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
|
||||
|
||||
### Native unit tests (C++)
|
||||
|
||||
Unit tests in `test/` directory with 12 test suites:
|
||||
Unit tests in `test/` directory with 17 test suites:
|
||||
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_mesh_module/` - Module framework
|
||||
- `test_meshpacket_serializer/` - Packet serialization
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_admin_radio/` - LoRa region/config validation and AdminModule dispatch
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_default/` - Default configuration
|
||||
- `test_http_content_handler/` - HTTP handling
|
||||
- `test_mac_from_string/` - MAC address parsing
|
||||
- `test_mesh_module/` - Module framework
|
||||
- `test_meshpacket_serializer/` - Packet serialization
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_packet_history/` - Packet history tracking
|
||||
- `test_position_precision/` - Position precision helpers
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_serial/` - Serial communication
|
||||
- `test_traffic_management/` - Traffic management
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
|
||||
- `test_utf8/` - UTF-8 utilities
|
||||
|
||||
Run with: `pio test -e native`
|
||||
Run command (preferred — avoids pipe-buffering and the Ubuntu externally-managed-environment error):
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_suite > /tmp/test_out.txt 2>&1
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
Do **not** use `pio test … | tail -N` — it discards build errors and shows stale cached results. Do **not** use `pio test … | grep` — line-buffering makes the terminal appear hung until the process exits. Redirect to a file first, then grep.
|
||||
|
||||
Simulation testing: `bin/test-simulator.sh`
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Post Firmware Size Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-size-comment:
|
||||
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:
|
||||
- name: Download size report
|
||||
id: download
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: size-report
|
||||
path: ./
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const marker = '<!-- firmware-size-report -->';
|
||||
const body = fs.readFileSync('./size-report.md', 'utf8');
|
||||
const prNumber = parseInt(fs.readFileSync('./pr-number.txt', 'utf8').trim(), 10);
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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 = [
|
||||
`})`,
|
||||
`, '2C2D3C')})`,
|
||||
`})`,
|
||||
];
|
||||
if (expiresAt) badges.push(`})`);
|
||||
|
||||
// Only render the board table when there are supported boards to list
|
||||
const boardTable = boards.length > 0 ? [
|
||||
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
|
||||
'',
|
||||
'| | Device | Board | Platform |',
|
||||
'| --- | --- | --- | --- |',
|
||||
boardLines,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
] : [];
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
`[](${flasherUrl})`,
|
||||
'',
|
||||
badges.join(' '),
|
||||
'',
|
||||
'> [!WARNING]',
|
||||
'> This is an automated, unreviewed CI test build. Back up your device configuration',
|
||||
'> before flashing, and only flash devices you are able to recover.',
|
||||
'',
|
||||
...boardTable,
|
||||
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
|
||||
].join('\n');
|
||||
|
||||
// Sticky comment: update in place when the marker is found
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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,
|
||||
});
|
||||
@@ -82,8 +82,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
check: ${{ fromJson(needs.setup.outputs.check) }}
|
||||
# Use 'arctastic' self-hosted runner pool when checking in the main repo
|
||||
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
|
||||
# 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
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -245,47 +246,126 @@ jobs:
|
||||
path: ./*.elf
|
||||
retention-days: 30
|
||||
|
||||
shame:
|
||||
firmware-size-report:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
|
||||
fetch-depth: 0
|
||||
- name: Download the current manifests
|
||||
|
||||
- name: Download current manifests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./manifests-new/
|
||||
path: ./manifests/
|
||||
pattern: manifest-*
|
||||
merge-multiple: true
|
||||
- name: Upload combined manifests for later commit and global stats crunching.
|
||||
|
||||
- name: Collect current firmware sizes
|
||||
run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json
|
||||
|
||||
- name: Upload size report artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
id: upload-manifest
|
||||
with:
|
||||
name: manifests-${{ github.sha }}
|
||||
name: firmware-sizes-${{ github.sha }}
|
||||
overwrite: true
|
||||
path: manifests-new/*.mt.json
|
||||
- name: Find the merge base
|
||||
path: ./current-sizes.json
|
||||
retention-days: 90
|
||||
|
||||
- name: Download baseline sizes from develop
|
||||
if: github.event_name == 'pull_request'
|
||||
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
|
||||
continue-on-error: true
|
||||
id: baseline-develop
|
||||
env:
|
||||
base: ${{ github.base_ref }}
|
||||
head: ${{ github.sha }}
|
||||
# Currently broken (for-loop through EVERY artifact -- rate limiting)
|
||||
# - name: Download the old manifests
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
|
||||
# env:
|
||||
# GH_TOKEN: ${{ github.token }}
|
||||
# merge_base: ${{ env.MERGE_BASE }}
|
||||
# repo: ${{ github.repository }}
|
||||
# - name: Do scan and post comment
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||
--workflow CI --branch develop --status success \
|
||||
--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)
|
||||
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
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download baseline sizes from master
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
id: baseline-master
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||
--workflow CI --branch master --status success \
|
||||
--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)
|
||||
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
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Generate size comparison report
|
||||
if: github.event_name == 'pull_request'
|
||||
id: report
|
||||
run: |
|
||||
ARGS="./current-sizes.json"
|
||||
if [ -f ./develop-sizes.json ]; then
|
||||
ARGS="$ARGS --baseline develop:./develop-sizes.json"
|
||||
fi
|
||||
if [ -f ./master-sizes.json ]; then
|
||||
ARGS="$ARGS --baseline master:./master-sizes.json"
|
||||
fi
|
||||
REPORT=$(python3 bin/size_report.py $ARGS)
|
||||
if [ -z "$REPORT" ]; then
|
||||
echo "has_report=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_report=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo '<!-- firmware-size-report -->'
|
||||
echo '# Firmware Size Report'
|
||||
echo ''
|
||||
echo "$REPORT"
|
||||
echo ''
|
||||
echo '---'
|
||||
echo "*Updated for ${{ github.sha }}*"
|
||||
} > ./size-report.md
|
||||
cat ./size-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Save PR number
|
||||
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||
run: echo "${{ github.event.pull_request.number }}" > ./pr-number.txt
|
||||
|
||||
- name: Upload size report
|
||||
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: size-report
|
||||
path: |
|
||||
./size-report.md
|
||||
./pr-number.txt
|
||||
retention-days: 5
|
||||
|
||||
release-artifacts:
|
||||
permissions: # Needed for 'gh release upload'.
|
||||
|
||||
@@ -37,13 +37,33 @@ 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()'
|
||||
wait
|
||||
# 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
|
||||
|
||||
- name: Capture coverage information
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -141,6 +161,14 @@ 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:
|
||||
|
||||
@@ -16,13 +16,18 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Update submodule
|
||||
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
|
||||
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 }}
|
||||
run: |
|
||||
git submodule update --remote protobufs
|
||||
git fetch --prune origin $GIT_BRANCH
|
||||
git checkout origin/$GIT_BRANCH
|
||||
|
||||
- name: Download nanopb
|
||||
run: |
|
||||
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/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
|
||||
|
||||
@@ -33,7 +38,7 @@ jobs:
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
branch: create-pull-request/update-protobufs
|
||||
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
|
||||
labels: submodules
|
||||
title: Update protobufs and classes
|
||||
commit-message: Update protobufs
|
||||
|
||||
@@ -10,18 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
|
||||
|
||||
## Quick command reference
|
||||
|
||||
| Action | Command |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `pio test -e native` |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
| Action | Command |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` then `grep -E 'error:\|PASS\|FAIL\|succeeded\|failed' /tmp/test_out.txt` (redirect first — piping causes line-buffering) |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
|
||||
## MCP server (device + test automation)
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **`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.
|
||||
@@ -108,7 +108,7 @@ Sequence these; don't parallelize on the same port.
|
||||
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
|
||||
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
|
||||
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
|
||||
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
|
||||
| `test/` | Firmware unit tests (17 suites; `pio test -e native`) |
|
||||
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
|
||||
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` |
|
||||
| `.claude/commands/` | Claude Code slash command bodies |
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
curl wget g++ zip git ca-certificates pkg-config \
|
||||
python3-pip python3-grpc-tools \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
@@ -53,7 +53,7 @@ ENV TZ=Etc/UTC
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get --no-install-recommends -y install \
|
||||
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libi2c0 libuv1t64 libusb-1.0-0-dev \
|
||||
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libi2c0 libuv1t64 libusb-1.0-0-dev \
|
||||
liborcania2.3 libulfius2.7t64 libssl3t64 \
|
||||
libx11-6 libinput10 libxkbcommon-x11-0 libsdl2-2.0-0 \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
g++ git ca-certificates pkg-config \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
+32
@@ -10,3 +10,35 @@
|
||||
## 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.
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apk --no-cache add \
|
||||
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
||||
py3-pip py3-grpcio-tools \
|
||||
libgpiod-dev yaml-cpp-dev bluez-dev \
|
||||
libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev \
|
||||
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
||||
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
@@ -48,7 +48,7 @@ LABEL org.opencontainers.image.title="Meshtastic" \
|
||||
USER root
|
||||
|
||||
RUN apk --no-cache add \
|
||||
shadow libstdc++ libbsd libgpiod yaml-cpp libusb \
|
||||
shadow libstdc++ libbsd libgpiod yaml-cpp jsoncpp libusb \
|
||||
i2c-tools libuv libx11 libinput libxkbcommon sdl2 \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& mkdir -p /var/lib/meshtasticd \
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Collect firmware binary sizes from manifest (.mt.json) files into a single report."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def collect_sizes(manifest_dir):
|
||||
"""Scan manifest_dir for .mt.json files and return {board: size_bytes} dict."""
|
||||
sizes = {}
|
||||
for fname in sorted(os.listdir(manifest_dir)):
|
||||
if not fname.endswith(".mt.json"):
|
||||
continue
|
||||
path = os.path.join(manifest_dir, fname)
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
board = data.get("platformioTarget", fname.replace(".mt.json", ""))
|
||||
# Find the main firmware .bin size (largest .bin, excluding OTA/littlefs/bleota)
|
||||
bin_size = None
|
||||
for entry in data.get("files", []):
|
||||
name = entry.get("name", "")
|
||||
if name.startswith("firmware-") and name.endswith(".bin"):
|
||||
bin_size = entry["bytes"]
|
||||
break
|
||||
# Fallback: any .bin that isn't ota/littlefs/bleota
|
||||
if bin_size is None:
|
||||
for entry in data.get("files", []):
|
||||
name = entry.get("name", "")
|
||||
if name.endswith(".bin") and not any(
|
||||
x in name for x in ["littlefs", "bleota", "ota"]
|
||||
):
|
||||
bin_size = entry["bytes"]
|
||||
break
|
||||
if bin_size is not None:
|
||||
sizes[board] = bin_size
|
||||
return sizes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <manifest_dir> <output.json>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
manifest_dir = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
sizes = collect_sizes(manifest_dir)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(sizes, f, indent=2, sort_keys=True)
|
||||
|
||||
print(f"Collected sizes for {len(sizes)} targets -> {output_path}")
|
||||
@@ -5,7 +5,9 @@ Meta:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
### RAK13300 in Slot 2 pins
|
||||
|
||||
### RAK13300 in Slot 2
|
||||
Module: sx1262
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
@@ -13,5 +15,7 @@ Lora:
|
||||
Enable_Pins:
|
||||
- 26
|
||||
- 23
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
@@ -5,14 +5,18 @@ Meta:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
### RAK13302 in Slot 2 pins
|
||||
|
||||
### RAK13302 in Slot 2
|
||||
Module: sx1262
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
# Ant_sw: 23 # IO3
|
||||
# Ant_sw: 23 # IO3
|
||||
Enable_Pins:
|
||||
- 26
|
||||
- 23
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/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()
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/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
|
||||
@@ -1,95 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from github import Github
|
||||
|
||||
def parseFile(path):
|
||||
with open(path, "r") as f:
|
||||
data = json.loads(f)
|
||||
for file in data["files"]:
|
||||
if file["name"].endswith(".bin"):
|
||||
return file["name"], file["bytes"]
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print(f"expected usage: {sys.argv[0]} <PR number> <path to old-manifests> <path to new-manifests>")
|
||||
sys.exit(1)
|
||||
|
||||
pr_number = int(sys.argv[1])
|
||||
|
||||
token = os.getenv("GITHUB_TOKEN")
|
||||
if not token:
|
||||
raise EnvironmentError("GITHUB_TOKEN not found in environment.")
|
||||
|
||||
repo_name = os.getenv("GITHUB_REPOSITORY") # "owner/repo"
|
||||
if not repo_name:
|
||||
raise EnvironmentError("GITHUB_REPOSITORY not found in environment.")
|
||||
|
||||
oldFiles = sys.argv[2]
|
||||
old = set(os.path.join(oldFiles, f) for f in os.listdir(oldFiles) if os.path.isfile(f))
|
||||
newFiles = sys.argv[3]
|
||||
new = set(os.path.join(newFiles, f) for f in os.listdir(newFiles) if os.path.isfile(f))
|
||||
|
||||
startMarkdown = "# Target Size Changes\n\n"
|
||||
markdown = ""
|
||||
|
||||
newlyIntroduced = new - old
|
||||
if len(newlyIntroduced) > 0:
|
||||
markdown += "## Newly Introduced Targets\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Size |\n"
|
||||
markdown += "| ---- | ---- |\n"
|
||||
for f in newlyIntroduced:
|
||||
name, size = parseFile(f)
|
||||
markdown += f"| `{name}` | {size}b |\n"
|
||||
|
||||
# do not log removed targets
|
||||
# PRs only run a small subset of builds, so removed targets are not meaningful
|
||||
# since they are very likely to just be not ran in PR CI
|
||||
|
||||
both = old & new
|
||||
degradations = []
|
||||
improvements = []
|
||||
for f in both:
|
||||
oldName, oldSize = parseFile(f)
|
||||
_, newSize = parseFile(f)
|
||||
if oldSize != newSize:
|
||||
if newSize < oldSize:
|
||||
improvements.append((oldName, oldSize, newSize))
|
||||
else:
|
||||
degradations.append((oldName, oldSize, newSize))
|
||||
|
||||
if len(degradations) > 0:
|
||||
markdown += "\n## Degradation\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
||||
for oldName, oldSize, newSize in degradations:
|
||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
||||
|
||||
if len(improvements) > 0:
|
||||
markdown += "\n## Improvement\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
||||
for oldName, oldSize, newSize in improvements:
|
||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
||||
|
||||
if len(markdown) == 0:
|
||||
markdown = "No changes in target sizes detected."
|
||||
|
||||
g = Github(token)
|
||||
repo = g.get_repo(repo_name)
|
||||
pr = repo.get_pull(pr_number)
|
||||
|
||||
existing_comment = None
|
||||
for comment in pr.get_issue_comments():
|
||||
if comment.body.startswith(startMarkdown):
|
||||
existing_comment = comment
|
||||
break
|
||||
|
||||
final_markdown = startMarkdown + markdown
|
||||
|
||||
if existing_comment:
|
||||
existing_comment.edit(body=final_markdown)
|
||||
else:
|
||||
pr.create_issue_comment(body=final_markdown)
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Compare firmware size reports and generate a markdown summary.
|
||||
|
||||
Usage:
|
||||
size_report.py <new_sizes.json> [--baseline <label>:<old_sizes.json>]...
|
||||
|
||||
Examples:
|
||||
# Compare PR against develop and master baselines
|
||||
size_report.py pr.json --baseline develop:develop.json --baseline master:master.json
|
||||
|
||||
# Single baseline comparison
|
||||
size_report.py pr.json --baseline develop:develop.json
|
||||
|
||||
# No baselines — shows sizes with blank delta columns
|
||||
size_report.py pr.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def load_sizes(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def format_delta(n):
|
||||
"""Format byte delta with sign and human-friendly suffix."""
|
||||
sign = "+" if n > 0 else ""
|
||||
if abs(n) >= 1024:
|
||||
return f"{sign}{n:,} ({sign}{n / 1024:.1f} KB)"
|
||||
return f"{sign}{n:,}"
|
||||
|
||||
|
||||
def generate_markdown(new_sizes, baselines, top_n=5):
|
||||
"""Generate a single table with current size and delta columns per baseline.
|
||||
|
||||
baselines: list of (label, old_sizes_dict), may be empty
|
||||
"""
|
||||
labels = [label for label, _ in baselines]
|
||||
|
||||
# Build rows: (board, current_size, [(delta, abs_delta) per baseline])
|
||||
rows = []
|
||||
for board in sorted(new_sizes):
|
||||
current = new_sizes[board]
|
||||
deltas = []
|
||||
for _, old_sizes in baselines:
|
||||
old = old_sizes.get(board)
|
||||
if old is not None:
|
||||
d = current - old
|
||||
deltas.append((d, abs(d)))
|
||||
else:
|
||||
deltas.append((None, 0))
|
||||
# Sort key: max abs delta across baselines (biggest changes first)
|
||||
max_abs = max((ad for _, ad in deltas), default=0)
|
||||
rows.append((board, current, deltas, max_abs))
|
||||
|
||||
rows.sort(key=lambda r: r[3], reverse=True)
|
||||
|
||||
# Summary line
|
||||
sections = []
|
||||
summary_parts = [f"{len(new_sizes)} targets"]
|
||||
for i, (label, old_sizes) in enumerate(baselines):
|
||||
increases = sum(
|
||||
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] > 0
|
||||
)
|
||||
decreases = sum(
|
||||
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] < 0
|
||||
)
|
||||
net = sum(
|
||||
deltas[i][0] for _, _, deltas, _ in rows if deltas[i][0] is not None
|
||||
)
|
||||
parts = []
|
||||
if increases:
|
||||
parts.append(f"{increases} increased")
|
||||
if decreases:
|
||||
parts.append(f"{decreases} decreased")
|
||||
if parts:
|
||||
parts.append(f"net {format_delta(net)}")
|
||||
summary_parts.append(f"vs `{label}`: {', '.join(parts)}")
|
||||
else:
|
||||
summary_parts.append(f"vs `{label}`: no changes")
|
||||
|
||||
if not baselines:
|
||||
summary_parts.append("no baseline available yet")
|
||||
|
||||
sections.append(f"**{' | '.join(summary_parts)}**\n")
|
||||
|
||||
# Table header
|
||||
header = "| Target | Size |"
|
||||
separator = "|--------|-----:|"
|
||||
for label in labels:
|
||||
header += f" vs `{label}` |"
|
||||
separator += "----------:|"
|
||||
sections.append(header)
|
||||
sections.append(separator)
|
||||
|
||||
def format_row(board, current, deltas):
|
||||
row = f"| `{board}` | {current:,} |"
|
||||
for d, _ in deltas:
|
||||
if d is None:
|
||||
row += " |"
|
||||
elif d == 0:
|
||||
row += " 0 |"
|
||||
else:
|
||||
icon = "📈" if d > 0 else "📉"
|
||||
row += f" {icon} {format_delta(d)} |"
|
||||
return row
|
||||
|
||||
# Top N rows always visible
|
||||
top = rows[:top_n]
|
||||
for board, current, deltas, _ in top:
|
||||
sections.append(format_row(board, current, deltas))
|
||||
|
||||
# Remaining rows in expandable section
|
||||
rest = rows[top_n:]
|
||||
if rest:
|
||||
sections.append("")
|
||||
sections.append(
|
||||
f"<details><summary>Show {len(rest)} more target(s)</summary>\n"
|
||||
)
|
||||
sections.append(header)
|
||||
sections.append(separator)
|
||||
for board, current, deltas, _ in rest:
|
||||
sections.append(format_row(board, current, deltas))
|
||||
sections.append("\n</details>")
|
||||
|
||||
sections.append("")
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compare firmware size reports")
|
||||
parser.add_argument("new_sizes", help="Path to new sizes JSON")
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="LABEL:PATH",
|
||||
help="Baseline to compare against (e.g. develop:develop.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of top changes to show before collapsing (default: 5)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
new_sizes = load_sizes(args.new_sizes)
|
||||
|
||||
# Silence output when no targets were built — repo maintainer choice
|
||||
if not new_sizes:
|
||||
return
|
||||
|
||||
baselines = []
|
||||
for b in args.baseline:
|
||||
if ":" not in b:
|
||||
print(f"Error: baseline must be LABEL:PATH, got '{b}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
label, path = b.split(":", 1)
|
||||
baselines.append((label, load_sizes(path)))
|
||||
|
||||
md = generate_markdown(new_sizes, baselines, top_n=args.top)
|
||||
print(md)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Tests for bin/collect_sizes.py and bin/size_report.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "bin")
|
||||
|
||||
|
||||
def make_manifest(target, firmware_bytes, extra_files=None):
|
||||
"""Create a minimal .mt.json manifest dict."""
|
||||
files = [{"name": f"firmware-{target}-2.6.0.bin", "bytes": firmware_bytes}]
|
||||
if extra_files:
|
||||
files.extend(extra_files)
|
||||
return {
|
||||
"platformioTarget": target,
|
||||
"version": "2.6.0.test",
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def write_manifests(tmpdir, manifests):
|
||||
"""Write manifest dicts as .mt.json files into tmpdir."""
|
||||
for target, data in manifests.items():
|
||||
path = os.path.join(tmpdir, f"firmware-{target}.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
def run_script(script, args):
|
||||
"""Run a Python script and return (returncode, stdout, stderr)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, script)] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def test_collect_sizes_basic():
|
||||
"""collect_sizes picks up firmware-*.bin entries from manifests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
manifests = {
|
||||
"heltec-v3": make_manifest("heltec-v3", 1048576),
|
||||
"rak4631": make_manifest("rak4631", 524288),
|
||||
"tbeam": make_manifest("tbeam", 786432),
|
||||
}
|
||||
write_manifests(tmpdir, manifests)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||
assert "3 targets" in stdout
|
||||
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert sizes == {"heltec-v3": 1048576, "rak4631": 524288, "tbeam": 786432}
|
||||
|
||||
|
||||
def test_collect_sizes_fallback_bin():
|
||||
"""collect_sizes falls back to non-firmware-prefixed .bin if no firmware-*.bin."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
# Manifest with only a generic .bin (no firmware- prefix)
|
||||
data = {
|
||||
"platformioTarget": "custom-board",
|
||||
"files": [
|
||||
{"name": "littlefs-custom-board.bin", "bytes": 100000},
|
||||
{"name": "custom-board.bin", "bytes": 500000},
|
||||
],
|
||||
}
|
||||
path = os.path.join(tmpdir, "firmware-custom-board.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert sizes == {"custom-board": 500000}
|
||||
|
||||
|
||||
def test_collect_sizes_skips_ota_littlefs():
|
||||
"""collect_sizes ignores ota/littlefs/bleota .bin files in fallback."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
data = {
|
||||
"platformioTarget": "board-x",
|
||||
"files": [
|
||||
{"name": "littlefs-board-x.bin", "bytes": 100000},
|
||||
{"name": "bleota-board-x.bin", "bytes": 50000},
|
||||
{"name": "mt-board-x-ota.bin", "bytes": 60000},
|
||||
],
|
||||
}
|
||||
path = os.path.join(tmpdir, "firmware-board-x.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
# No valid firmware .bin found, board should be absent
|
||||
assert sizes == {}
|
||||
|
||||
|
||||
def test_collect_sizes_ignores_non_mt_json():
|
||||
"""collect_sizes skips non .mt.json files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
# Write a valid manifest
|
||||
manifests = {"rak4631": make_manifest("rak4631", 500000)}
|
||||
write_manifests(tmpdir, manifests)
|
||||
# Write a decoy file
|
||||
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
|
||||
f.write("not a manifest")
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert list(sizes.keys()) == ["rak4631"]
|
||||
|
||||
|
||||
def test_size_report_no_baseline():
|
||||
"""size_report with no baselines shows sizes only."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "new.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1000000, "rak4631": 500000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script("size_report.py", [sizes_file])
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "2 targets" in stdout
|
||||
assert "no baseline available yet" in stdout
|
||||
assert "`heltec-v3`" in stdout
|
||||
assert "`rak4631`" in stdout
|
||||
|
||||
|
||||
def test_size_report_with_baseline():
|
||||
"""size_report shows deltas against a baseline."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
old_file = os.path.join(tmpdir, "old.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1050000, "rak4631": 500000, "tbeam": 800000}, f)
|
||||
with open(old_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1000000, "rak4631": 500000, "tbeam": 810000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "3 targets" in stdout
|
||||
assert "1 increased" in stdout
|
||||
assert "1 decreased" in stdout
|
||||
# heltec-v3 grew by 50000
|
||||
assert "📈" in stdout
|
||||
# tbeam shrank by 10000
|
||||
assert "📉" in stdout
|
||||
# rak4631 unchanged
|
||||
assert "vs `develop`" in stdout
|
||||
|
||||
|
||||
def test_size_report_multiple_baselines():
|
||||
"""size_report handles multiple baselines."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
dev_file = os.path.join(tmpdir, "develop.json")
|
||||
master_file = os.path.join(tmpdir, "master.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"board-a": 100000}, f)
|
||||
with open(dev_file, "w") as f:
|
||||
json.dump({"board-a": 95000}, f)
|
||||
with open(master_file, "w") as f:
|
||||
json.dump({"board-a": 90000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py",
|
||||
[new_file, "--baseline", f"develop:{dev_file}", "--baseline", f"master:{master_file}"],
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "vs `develop`" in stdout
|
||||
assert "vs `master`" in stdout
|
||||
|
||||
|
||||
def test_size_report_new_target_no_baseline_entry():
|
||||
"""size_report handles targets not present in baseline (new boards)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
old_file = os.path.join(tmpdir, "old.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"new-board": 300000, "existing": 500000}, f)
|
||||
with open(old_file, "w") as f:
|
||||
json.dump({"existing": 500000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "`new-board`" in stdout
|
||||
assert "no changes" in stdout # only existing is compared, delta=0
|
||||
|
||||
|
||||
def test_size_report_all_unchanged():
|
||||
"""size_report shows 'no changes' when all sizes match."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"board-a": 100000, "board-b": 200000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [sizes_file, "--baseline", f"develop:{sizes_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "no changes" in stdout
|
||||
|
||||
|
||||
def test_collect_sizes_bad_args():
|
||||
"""collect_sizes exits with error on wrong arg count."""
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [])
|
||||
assert rc == 1
|
||||
assert "Usage" in stderr
|
||||
|
||||
|
||||
def test_size_report_bad_baseline_format():
|
||||
"""size_report exits with error on malformed --baseline."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"x": 1}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [sizes_file, "--baseline", "no-colon-here"]
|
||||
)
|
||||
assert rc == 1
|
||||
assert "LABEL:PATH" in stderr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in globals().items() if k.startswith("test_")]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {type(e).__name__}: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed out of {passed + failed}")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
Vendored
+1
@@ -14,6 +14,7 @@ Build-Depends: debhelper-compat (= 13),
|
||||
g++,
|
||||
pkg-config,
|
||||
libyaml-cpp-dev,
|
||||
libjsoncpp-dev,
|
||||
libgpiod-dev,
|
||||
libbluetooth-dev,
|
||||
libusb-1.0-0-dev,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/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)
|
||||
@@ -191,10 +191,12 @@ echo
|
||||
# PASS/FAIL — every hardware test would SKIP with "role not present". We
|
||||
# narrow to tests/unit explicitly so the summary reads as "no hardware,
|
||||
# unit suite only" instead of "big skip count looks suspicious".
|
||||
# Keep terminal output condensed (`-q -r fE`) so skip-heavy runs do not print
|
||||
# each skipped test in full; skip counts still appear in pytest's summary.
|
||||
if [[ -z $DETECTED && $# -eq 0 ]]; then
|
||||
echo "[pre-flight] no supported devices detected; running unit tier only."
|
||||
echo
|
||||
exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl
|
||||
exec "$VENV_PY" -m pytest tests/unit -q -r fE --report-log=tests/reportlog.jsonl
|
||||
fi
|
||||
|
||||
# Default pytest args when the user passed none. Power users can invoke
|
||||
@@ -210,11 +212,13 @@ fi
|
||||
# skipping half the hardware tests with "not baked with session profile"
|
||||
# errors. Power users who know their hardware is current and want to shave
|
||||
# those seconds can pass `--assume-baked` explicitly.
|
||||
# Defaults also use condensed reporting (`-q -r fE`) to avoid listing every
|
||||
# skipped test verbatim while still surfacing failures/errors and summary data.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
set -- tests/ \
|
||||
--html=tests/report.html --self-contained-html \
|
||||
--junitxml=tests/junit.xml \
|
||||
-v --tb=short
|
||||
-q -r fE --tb=short
|
||||
fi
|
||||
|
||||
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
|
||||
|
||||
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
|
||||
"esp32-c6",
|
||||
"esp32c6",
|
||||
}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840"}
|
||||
|
||||
|
||||
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
|
||||
|
||||
@@ -34,6 +34,7 @@ BuildRequires: python3dist(grpcio-tools)
|
||||
BuildRequires: git-core
|
||||
BuildRequires: gcc-c++
|
||||
BuildRequires: pkgconfig(yaml-cpp)
|
||||
BuildRequires: pkgconfig(jsoncpp)
|
||||
BuildRequires: pkgconfig(libgpiod)
|
||||
BuildRequires: pkgconfig(bluez)
|
||||
BuildRequires: pkgconfig(libusb-1.0)
|
||||
|
||||
+19
-25
@@ -103,7 +103,7 @@ build_unflags =
|
||||
-std=gnu++11
|
||||
build_flags = ${env.build_flags} -Os
|
||||
-std=gnu++17
|
||||
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/>
|
||||
build_src_filter = ${env.build_src_filter} -<platform/> +<platform/extra_variants/> -<graphics/niche/>
|
||||
|
||||
; Common libs for communicating over TCP/IP networks such as MQTT
|
||||
[networking_base]
|
||||
@@ -200,30 +200,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
|
||||
|
||||
; Common environmental sensor libraries (not included in native / portduino)
|
||||
[environmental_extra_common]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=Adafruit BMP3XX packageName=adafruit/Adafruit_BMP3XX
|
||||
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
|
||||
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHTC3 packageName=adafruit/Adafruit_SHTC3
|
||||
https://github.com/adafruit/Adafruit_SHTC3/archive/refs/tags/1.0.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
|
||||
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT31 packageName=adafruit/Adafruit_SHT31
|
||||
https://github.com/adafruit/Adafruit_SHT31/archive/refs/tags/2.2.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit VEML7700 packageName=adafruit/Adafruit_VEML7700
|
||||
https://github.com/adafruit/Adafruit_VEML7700/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT4x packageName=adafruit/Adafruit_SHT4X
|
||||
https://github.com/adafruit/Adafruit_SHT4X/archive/refs/tags/1.0.5.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun Qwiic Scale NAU7802 packageName=sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library/archive/refs/tags/v1.0.6.zip
|
||||
# renovate: datasource=custom.pio depName=ClosedCube OPT3001 packageName=closedcube/library/ClosedCube OPT3001
|
||||
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
|
||||
@@ -235,6 +211,24 @@ lib_deps =
|
||||
# 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]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=Adafruit BMP3XX packageName=adafruit/Adafruit_BMP3XX
|
||||
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
|
||||
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
|
||||
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit VEML7700 packageName=adafruit/Adafruit_VEML7700
|
||||
https://github.com/adafruit/Adafruit_VEML7700/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun Qwiic Scale NAU7802 packageName=sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library/archive/refs/tags/v1.0.6.zip
|
||||
# renovate: datasource=custom.pio depName=ClosedCube OPT3001 packageName=closedcube/library/ClosedCube OPT3001
|
||||
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
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
lib_deps =
|
||||
|
||||
+1
-1
Submodule protobufs updated: 519a0c7c9c...3625166717
@@ -51,6 +51,12 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
||||
case PRESET(NARROW_SLOW):
|
||||
return useShortName ? "NarS" : "NarrowSlow";
|
||||
break;
|
||||
case PRESET(TINY_FAST):
|
||||
return useShortName ? "TinyF" : "TinyFast";
|
||||
break;
|
||||
case PRESET(TINY_SLOW):
|
||||
return useShortName ? "TinyS" : "TinySlow";
|
||||
break;
|
||||
default:
|
||||
return useShortName ? "Custom" : "Invalid";
|
||||
break;
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@
|
||||
#define FILE_O_READ "r"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL)
|
||||
// STM32WL
|
||||
#if defined(ARCH_STM32)
|
||||
// STM32
|
||||
#include "LittleFS.h"
|
||||
#define FSCom InternalFS
|
||||
#define FSBegin() FSCom.begin()
|
||||
|
||||
@@ -354,6 +354,7 @@ 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"
|
||||
|
||||
+11
-6
@@ -14,6 +14,7 @@
|
||||
* For more information, see: https://meshtastic.org/
|
||||
*/
|
||||
#include "power.h"
|
||||
#include "BluetoothCommon.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PowerFSM.h"
|
||||
@@ -47,7 +48,7 @@
|
||||
#include "concurrency/LockGuard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
|
||||
#if defined(ARCH_STM32) && defined(BATTERY_PIN)
|
||||
#include "stm32yyxx_ll_adc.h"
|
||||
|
||||
/* Analog read resolution */
|
||||
@@ -430,7 +431,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
// STM32 ADC with VREFINT runtime calibration
|
||||
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
|
||||
raw = analogRead(BATTERY_PIN);
|
||||
@@ -607,7 +608,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_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
// 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;
|
||||
@@ -717,7 +718,7 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
|
||||
adc_oneshot_unit_init_cfg_t init_config = {
|
||||
@@ -748,7 +749,7 @@ bool Power::analogInit()
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32)
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
@@ -837,7 +838,7 @@ void Power::reboot()
|
||||
}
|
||||
LOG_DEBUG("final reboot!");
|
||||
::reboot();
|
||||
#elif defined(ARCH_STM32WL)
|
||||
#elif defined(ARCH_STM32)
|
||||
HAL_NVIC_SystemReset();
|
||||
#else
|
||||
rebootAtMsec = -1;
|
||||
@@ -962,6 +963,10 @@ 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
|
||||
|
||||
@@ -219,7 +219,11 @@ 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);
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,6 +1,16 @@
|
||||
// TODO refactor this out with better radio configuration system
|
||||
#ifdef USE_RF95
|
||||
|
||||
#ifndef RF95_RESET
|
||||
#define RF95_RESET LORA_RESET
|
||||
#define RF95_IRQ LORA_DIO0 // on SX1262 version this is a no connect DIO0
|
||||
#endif
|
||||
|
||||
#ifndef RF95_IRQ
|
||||
#define RF95_IRQ LORA_DIO0 // on SX1262 version this is a no connect DIO0
|
||||
#endif
|
||||
|
||||
#ifndef RF95_DIO1
|
||||
#define RF95_DIO1 LORA_DIO1 // Note: not really used for RF95, but used for pure SX127x
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
@@ -51,7 +51,7 @@ size_t RedirectablePrint::write(uint8_t c)
|
||||
size_t RedirectablePrint::vprintf(const char *logLevel, const char *format, va_list arg)
|
||||
{
|
||||
va_list copy;
|
||||
#if ENABLE_JSON_LOGGING || ARCH_PORTDUINO
|
||||
#if ARCH_PORTDUINO
|
||||
static char printBuf[512];
|
||||
#else
|
||||
static char printBuf[160];
|
||||
|
||||
@@ -179,6 +179,13 @@ 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
|
||||
@@ -573,5 +580,94 @@ 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"
|
||||
|
||||
@@ -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};
|
||||
return firstOfOrNONE(11, types);
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
|
||||
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
|
||||
return firstOfOrNONE(12, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MMC5983MA};
|
||||
return firstOfOrNONE(1, types);
|
||||
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
|
||||
return firstOfOrNONE(2, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAQI() const
|
||||
|
||||
@@ -99,6 +99,8 @@ class ScanI2C
|
||||
CW2015,
|
||||
SCD30,
|
||||
ADS1115,
|
||||
IIS2MDCTR,
|
||||
ISM330DHCX,
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#include "linux/LinuxHardwareI2C.h"
|
||||
#endif
|
||||
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
|
||||
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
|
||||
#include "meshUtils.h" // vformat
|
||||
|
||||
#endif
|
||||
@@ -584,6 +584,9 @@ 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);
|
||||
@@ -591,7 +594,17 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
|
||||
SCAN_SIMPLE_CASE(QMC5883L_ADDR, QMC5883L, "QMC5883L", (uint8_t)addr.address)
|
||||
SCAN_SIMPLE_CASE(HMC5883L_ADDR, HMC5883L, "HMC5883L", (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;
|
||||
}
|
||||
#ifdef HAS_QMA6100P
|
||||
SCAN_SIMPLE_CASE(QMA6100P_ADDR, QMA6100P, "QMA6100P", (uint8_t)addr.address)
|
||||
#else
|
||||
|
||||
+361
-18
@@ -17,7 +17,10 @@
|
||||
#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"
|
||||
|
||||
@@ -44,7 +47,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_STM32WL)
|
||||
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
HardwareSerial *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
#elif defined(ARCH_RP2040)
|
||||
SerialUART *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
@@ -71,6 +74,112 @@ 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)
|
||||
{
|
||||
@@ -492,6 +601,201 @@ 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.
|
||||
@@ -504,24 +808,39 @@ bool GPS::setup()
|
||||
if (!didSerialInit) {
|
||||
int msglen = 0;
|
||||
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
if (probeTries < GPS_PROBETRIES) {
|
||||
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.
|
||||
gnssModel = probe(serialSpeeds[speedSelect]);
|
||||
if (gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
|
||||
speedSelect = 0;
|
||||
++probeTries;
|
||||
}
|
||||
if (gnssModel != GNSS_MODEL_UNKNOWN) {
|
||||
detectedBaud = serialSpeeds[speedSelect];
|
||||
} else if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
|
||||
speedSelect = 0;
|
||||
++probeTries;
|
||||
}
|
||||
}
|
||||
// Rare Serial Speeds
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32C6
|
||||
if (probeTries == GPS_PROBETRIES) {
|
||||
else if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries == GPS_PROBETRIES) {
|
||||
// Then try less common baud rates before giving up.
|
||||
gnssModel = probe(rareSerialSpeeds[speedSelect]);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -529,6 +848,7 @@ bool GPS::setup()
|
||||
|
||||
if (gnssModel != GNSS_MODEL_UNKNOWN) {
|
||||
setConnected();
|
||||
(void)saveProbeCache();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -840,10 +1160,22 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
|
||||
switch (newState) {
|
||||
case GPS_ACTIVE:
|
||||
case GPS_IDLE:
|
||||
if (oldState == GPS_ACTIVE || oldState == GPS_IDLE) // If hardware already awake, no changes needed
|
||||
if (oldState == GPS_ACTIVE)
|
||||
break;
|
||||
gotTime = false;
|
||||
if (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
|
||||
@@ -1102,6 +1434,11 @@ 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();
|
||||
@@ -1150,8 +1487,7 @@ 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
|
||||
bool gotTime = (getRTCQuality() >= RTCQualityGPS);
|
||||
// 1. Got a time for the first time this cycle
|
||||
if (!gotTime && lookForTime()) { // Note: we count on this && short-circuiting and not resetting the RTC time
|
||||
gotTime = true;
|
||||
}
|
||||
@@ -1277,7 +1613,7 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
|
||||
switch (currentStep) {
|
||||
case 0: {
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
_serial_gps->end();
|
||||
_serial_gps->begin(serialSpeed);
|
||||
#elif defined(ARCH_RP2040)
|
||||
@@ -1298,6 +1634,9 @@ 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 = {
|
||||
@@ -1350,6 +1689,7 @@ 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
|
||||
@@ -1634,7 +1974,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_STM32WL)
|
||||
#elif defined(ARCH_STM32)
|
||||
_serial_gps->setTx(new_gps->tx_gpio);
|
||||
_serial_gps->setRx(new_gps->rx_gpio);
|
||||
_serial_gps->begin(GPS_BAUDRATE);
|
||||
@@ -1681,6 +2021,9 @@ 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());
|
||||
|
||||
@@ -155,14 +155,26 @@ 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
|
||||
@@ -178,6 +190,10 @@ 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
@@ -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, (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
|
||||
} 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
|
||||
shouldSet = true;
|
||||
LOG_DEBUG("Reapply external time to correct clock drift %ld secs", printableEpoch);
|
||||
} else {
|
||||
|
||||
+328
-26
@@ -41,6 +41,7 @@ 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"
|
||||
@@ -50,6 +51,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "RadioLibInterface.h"
|
||||
#include "SPILock.h"
|
||||
#include "error.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "gps/RTC.h"
|
||||
@@ -118,8 +120,76 @@ 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
|
||||
@@ -163,6 +233,16 @@ 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);
|
||||
@@ -174,37 +254,30 @@ void Screen::setHeading(float heading)
|
||||
}
|
||||
|
||||
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
|
||||
float delta = wrappedHeading - compassHeading;
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
float delta = wrapDelta180(wrappedHeading - compassHeading);
|
||||
|
||||
// Adaptive filtering:
|
||||
// - Strong damping for tiny deltas (jitter)
|
||||
// - Faster response for larger turns
|
||||
const float absDelta = (delta >= 0.0f) ? delta : -delta;
|
||||
if (absDelta < 1.0f) {
|
||||
return;
|
||||
}
|
||||
if (absDelta >= 1.0f) {
|
||||
float alpha = 0.35f;
|
||||
if (absDelta > 25.0f) {
|
||||
alpha = 0.85f;
|
||||
} else if (absDelta > 10.0f) {
|
||||
alpha = 0.65f;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
float step = delta * alpha;
|
||||
const float maxStep = 12.0f;
|
||||
if (step > maxStep) {
|
||||
step = maxStep;
|
||||
} else if (step < -maxStep) {
|
||||
step = -maxStep;
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
@@ -582,6 +655,21 @@ 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)
|
||||
@@ -655,6 +743,10 @@ void Screen::setup()
|
||||
brightness = uiconfig.screen_brightness;
|
||||
}
|
||||
|
||||
// Restore which frames the user has hidden (persisted across reboots).
|
||||
// Must happen before the first setFrames().
|
||||
loadFrameVisibility();
|
||||
|
||||
// Detect OLED subtype (if supported by board variant)
|
||||
#ifdef AutoOLEDWire_h
|
||||
if (isAUTOOled)
|
||||
@@ -697,6 +789,27 @@ 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
|
||||
@@ -804,10 +917,16 @@ void Screen::setOn(bool on, FrameCallback einkScreensaver)
|
||||
if (cardKbI2cImpl)
|
||||
cardKbI2cImpl->toggleBacklight(on);
|
||||
#endif
|
||||
if (!on)
|
||||
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
|
||||
// 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});
|
||||
}
|
||||
|
||||
@@ -914,7 +1033,17 @@ int32_t Screen::runOnce()
|
||||
#endif
|
||||
|
||||
#ifndef DISABLE_WELCOME_UNSET
|
||||
if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_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 defined(OLED_TINY)
|
||||
menuHandler::LoraRegionPicker();
|
||||
#else
|
||||
@@ -1416,6 +1545,9 @@ void Screen::toggleFrameVisibility(const std::string &frameName)
|
||||
if (frameName == "chirpy") {
|
||||
hiddenFrames.chirpy = !hiddenFrames.chirpy;
|
||||
}
|
||||
|
||||
// Save the new visibility state so it survives a reboot.
|
||||
saveFrameVisibility();
|
||||
}
|
||||
|
||||
bool Screen::isFrameHidden(const std::string &frameName) const
|
||||
@@ -1454,6 +1586,167 @@ bool Screen::isFrameHidden(const std::string &frameName) const
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame visibility persistence
|
||||
//
|
||||
// The set of hideable frames varies by build (USE_EINK, HAS_GPS, ...), so we
|
||||
// serialize to a fixed bitmask where each frame name owns a permanent bit
|
||||
// position. Bits for frames that don't exist in the current build are simply
|
||||
// left untouched, which keeps the saved file portable across firmware variants.
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace
|
||||
{
|
||||
static const char *frameVisibilityFileName = "/prefs/framevis";
|
||||
constexpr uint32_t FRAMEVIS_MAGIC = 0x53495646; // "FVIS" little-endian
|
||||
constexpr uint8_t FRAMEVIS_VERSION = 1;
|
||||
|
||||
// Permanent bit assignments. Never renumber these; only append new ones.
|
||||
enum FrameVisBit : uint8_t {
|
||||
FVBIT_TEXT_MESSAGE = 0,
|
||||
FVBIT_WAYPOINT = 1,
|
||||
FVBIT_WIFI = 2,
|
||||
FVBIT_SYSTEM = 3,
|
||||
FVBIT_HOME = 4,
|
||||
FVBIT_CLOCK = 5,
|
||||
FVBIT_NODELIST_NODES = 6,
|
||||
FVBIT_NODELIST_LOCATION = 7,
|
||||
FVBIT_NODELIST_LASTHEARD = 8,
|
||||
FVBIT_NODELIST_HOPSIGNAL = 9,
|
||||
FVBIT_NODELIST_DISTANCE = 10,
|
||||
FVBIT_NODELIST_BEARINGS = 11,
|
||||
FVBIT_GPS = 12,
|
||||
FVBIT_LORA = 13,
|
||||
FVBIT_SHOW_FAVORITES = 14,
|
||||
FVBIT_CHIRPY = 15,
|
||||
};
|
||||
|
||||
struct __attribute__((packed)) FrameVisFile {
|
||||
uint32_t magic;
|
||||
uint8_t version;
|
||||
uint32_t mask;
|
||||
};
|
||||
|
||||
inline void setBit(uint32_t &mask, uint8_t bit, bool value)
|
||||
{
|
||||
if (value)
|
||||
mask |= (1UL << bit);
|
||||
else
|
||||
mask &= ~(1UL << bit);
|
||||
}
|
||||
|
||||
inline bool getBit(uint32_t mask, uint8_t bit)
|
||||
{
|
||||
return (mask & (1UL << bit)) != 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
uint32_t Screen::packHiddenFrames() const
|
||||
{
|
||||
uint32_t mask = 0;
|
||||
setBit(mask, FVBIT_TEXT_MESSAGE, hiddenFrames.textMessage);
|
||||
setBit(mask, FVBIT_WAYPOINT, hiddenFrames.waypoint);
|
||||
setBit(mask, FVBIT_WIFI, hiddenFrames.wifi);
|
||||
setBit(mask, FVBIT_SYSTEM, hiddenFrames.system);
|
||||
setBit(mask, FVBIT_HOME, hiddenFrames.home);
|
||||
setBit(mask, FVBIT_CLOCK, hiddenFrames.clock);
|
||||
#ifndef USE_EINK
|
||||
setBit(mask, FVBIT_NODELIST_NODES, hiddenFrames.nodelist_nodes);
|
||||
setBit(mask, FVBIT_NODELIST_LOCATION, hiddenFrames.nodelist_location);
|
||||
#endif
|
||||
#ifdef USE_EINK
|
||||
setBit(mask, FVBIT_NODELIST_LASTHEARD, hiddenFrames.nodelist_lastheard);
|
||||
setBit(mask, FVBIT_NODELIST_HOPSIGNAL, hiddenFrames.nodelist_hopsignal);
|
||||
setBit(mask, FVBIT_NODELIST_DISTANCE, hiddenFrames.nodelist_distance);
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
#ifdef USE_EINK
|
||||
setBit(mask, FVBIT_NODELIST_BEARINGS, hiddenFrames.nodelist_bearings);
|
||||
#endif
|
||||
setBit(mask, FVBIT_GPS, hiddenFrames.gps);
|
||||
#endif
|
||||
setBit(mask, FVBIT_LORA, hiddenFrames.lora);
|
||||
setBit(mask, FVBIT_SHOW_FAVORITES, hiddenFrames.show_favorites);
|
||||
setBit(mask, FVBIT_CHIRPY, hiddenFrames.chirpy);
|
||||
return mask;
|
||||
}
|
||||
|
||||
void Screen::applyHiddenFramesMask(uint32_t mask)
|
||||
{
|
||||
hiddenFrames.textMessage = getBit(mask, FVBIT_TEXT_MESSAGE);
|
||||
hiddenFrames.waypoint = getBit(mask, FVBIT_WAYPOINT);
|
||||
hiddenFrames.wifi = getBit(mask, FVBIT_WIFI);
|
||||
hiddenFrames.system = getBit(mask, FVBIT_SYSTEM);
|
||||
hiddenFrames.home = getBit(mask, FVBIT_HOME);
|
||||
hiddenFrames.clock = getBit(mask, FVBIT_CLOCK);
|
||||
#ifndef USE_EINK
|
||||
hiddenFrames.nodelist_nodes = getBit(mask, FVBIT_NODELIST_NODES);
|
||||
hiddenFrames.nodelist_location = getBit(mask, FVBIT_NODELIST_LOCATION);
|
||||
#endif
|
||||
#ifdef USE_EINK
|
||||
hiddenFrames.nodelist_lastheard = getBit(mask, FVBIT_NODELIST_LASTHEARD);
|
||||
hiddenFrames.nodelist_hopsignal = getBit(mask, FVBIT_NODELIST_HOPSIGNAL);
|
||||
hiddenFrames.nodelist_distance = getBit(mask, FVBIT_NODELIST_DISTANCE);
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
#ifdef USE_EINK
|
||||
hiddenFrames.nodelist_bearings = getBit(mask, FVBIT_NODELIST_BEARINGS);
|
||||
#endif
|
||||
hiddenFrames.gps = getBit(mask, FVBIT_GPS);
|
||||
#endif
|
||||
hiddenFrames.lora = getBit(mask, FVBIT_LORA);
|
||||
hiddenFrames.show_favorites = getBit(mask, FVBIT_SHOW_FAVORITES);
|
||||
hiddenFrames.chirpy = getBit(mask, FVBIT_CHIRPY);
|
||||
}
|
||||
|
||||
void Screen::loadFrameVisibility()
|
||||
{
|
||||
#ifdef FSCom
|
||||
spiLock->lock();
|
||||
auto file = FSCom.open(frameVisibilityFileName, FILE_O_READ);
|
||||
if (file) {
|
||||
FrameVisFile data{};
|
||||
bool ok = file.read((uint8_t *)&data, sizeof(data)) == sizeof(data) && data.magic == FRAMEVIS_MAGIC &&
|
||||
data.version == FRAMEVIS_VERSION;
|
||||
file.close();
|
||||
spiLock->unlock();
|
||||
if (ok) {
|
||||
applyHiddenFramesMask(data.mask);
|
||||
LOG_INFO("Loaded frame visibility (mask 0x%08x)", data.mask);
|
||||
} else {
|
||||
LOG_WARN("Frame visibility file invalid, keeping defaults");
|
||||
}
|
||||
return;
|
||||
}
|
||||
spiLock->unlock();
|
||||
LOG_DEBUG("No saved frame visibility, using defaults");
|
||||
#endif
|
||||
}
|
||||
|
||||
void Screen::saveFrameVisibility()
|
||||
{
|
||||
#ifdef FSCom
|
||||
spiLock->lock();
|
||||
FSCom.mkdir("/prefs");
|
||||
if (FSCom.exists(frameVisibilityFileName))
|
||||
FSCom.remove(frameVisibilityFileName);
|
||||
|
||||
auto file = FSCom.open(frameVisibilityFileName, FILE_O_WRITE);
|
||||
if (file) {
|
||||
FrameVisFile data{};
|
||||
data.magic = FRAMEVIS_MAGIC;
|
||||
data.version = FRAMEVIS_VERSION;
|
||||
data.mask = packHiddenFrames();
|
||||
file.write((uint8_t *)&data, sizeof(data));
|
||||
file.flush();
|
||||
file.close();
|
||||
LOG_INFO("Saved frame visibility (mask 0x%08x)", data.mask);
|
||||
} else {
|
||||
LOG_WARN("Failed to open %s for writing", frameVisibilityFileName);
|
||||
}
|
||||
spiLock->unlock();
|
||||
#endif
|
||||
}
|
||||
|
||||
void Screen::handleStartFirmwareUpdateScreen()
|
||||
{
|
||||
LOG_DEBUG("Show firmware screen");
|
||||
@@ -1466,6 +1759,15 @@ 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);
|
||||
|
||||
+25
-1
@@ -12,7 +12,21 @@
|
||||
#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
|
||||
namespace graphics
|
||||
{
|
||||
enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input };
|
||||
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,
|
||||
};
|
||||
|
||||
struct BannerOverlayOptions {
|
||||
const char *message;
|
||||
@@ -623,6 +637,11 @@ class Screen : public concurrency::OSThread
|
||||
void toggleFrameVisibility(const std::string &frameName);
|
||||
bool isFrameHidden(const std::string &frameName) const;
|
||||
|
||||
// Persist / restore which frames are hidden, across reboots.
|
||||
// Stored as a single uint32 bitmask in /prefs (see Screen.cpp for the format).
|
||||
void loadFrameVisibility();
|
||||
void saveFrameVisibility();
|
||||
|
||||
#ifdef USE_EINK
|
||||
/// Draw an image to remain on E-Ink display after screen off
|
||||
void setScreensaverFrames(FrameCallback einkScreensaver = NULL);
|
||||
@@ -738,6 +757,11 @@ class Screen : public concurrency::OSThread
|
||||
bool chirpy = true;
|
||||
} hiddenFrames;
|
||||
|
||||
// Convert hiddenFrames to a uint32 bitmask. Bit positions are fixed per
|
||||
// frame name (see Screen.cpp).
|
||||
uint32_t packHiddenFrames() const;
|
||||
void applyHiddenFramesMask(uint32_t mask);
|
||||
|
||||
/// Try to start drawing ASAP
|
||||
void setFastFramerate();
|
||||
|
||||
|
||||
@@ -578,7 +578,11 @@ 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;
|
||||
|
||||
@@ -1533,8 +1533,7 @@ 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;
|
||||
@@ -1553,8 +1552,7 @@ 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;
|
||||
|
||||
@@ -183,9 +183,13 @@ 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)
|
||||
float max_scale = 3.5f; // Safety limit to avoid runaway scaling
|
||||
float step = 0.05f; // Step increment per iteration
|
||||
#endif
|
||||
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 =
|
||||
|
||||
@@ -126,6 +126,7 @@ 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;
|
||||
|
||||
@@ -174,6 +175,48 @@ 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[] = {
|
||||
@@ -206,6 +249,10 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
{"KZ_863", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KZ_863},
|
||||
{"NP_865", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_NP_865},
|
||||
{"BR_902", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_BR_902},
|
||||
{"ITU1_2M (144-146)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M},
|
||||
{"ITU2_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M},
|
||||
{"ITU3_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M},
|
||||
{"ITU2_125CM (220-225)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM},
|
||||
|
||||
};
|
||||
|
||||
@@ -228,37 +275,34 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: without a reboot, reconfigure() applies the region directly.
|
||||
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
|
||||
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora
|
||||
// rather than a hardcoded check for LORA_24.
|
||||
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
|
||||
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
|
||||
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection");
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
config.lora.region = selectedRegion;
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
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);
|
||||
}
|
||||
#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;
|
||||
@@ -275,6 +319,38 @@ 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"};
|
||||
@@ -1496,6 +1572,7 @@ 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);
|
||||
@@ -1539,15 +1616,23 @@ 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);
|
||||
} else {
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||
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();
|
||||
}
|
||||
nodeDB->notifyObservers(true);
|
||||
nodeDB->saveToDisk();
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
||||
return;
|
||||
}
|
||||
@@ -2818,6 +2903,12 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case ThemeMenu:
|
||||
themeMenu();
|
||||
break;
|
||||
case HamModeConfirm:
|
||||
hamModeConfirmMenu();
|
||||
break;
|
||||
case LicensedToNormalConfirm:
|
||||
licensedToNormalConfirmMenu();
|
||||
break;
|
||||
}
|
||||
menuQueue = MenuNone;
|
||||
}
|
||||
|
||||
@@ -55,10 +55,13 @@ class menuHandler
|
||||
FrameToggles,
|
||||
DisplayUnits,
|
||||
MessageBubblesMenu,
|
||||
ThemeMenu
|
||||
ThemeMenu,
|
||||
HamModeConfirm,
|
||||
LicensedToNormalConfirm
|
||||
};
|
||||
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);
|
||||
@@ -111,6 +114,8 @@ class menuHandler
|
||||
static void messageBubblesMenu();
|
||||
static void themeMenu();
|
||||
static void textMessageMenu();
|
||||
static void hamModeConfirmMenu();
|
||||
static void licensedToNormalConfirmMenu();
|
||||
|
||||
private:
|
||||
static void saveUIConfig();
|
||||
|
||||
@@ -66,6 +66,15 @@ 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] = {};
|
||||
@@ -251,6 +260,12 @@ 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:
|
||||
@@ -259,6 +274,9 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
|
||||
case notificationTypeEnum::number_picker:
|
||||
drawNumberPicker(display, state);
|
||||
break;
|
||||
case notificationTypeEnum::hex_picker:
|
||||
drawHexPicker(display, state);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +363,105 @@ 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;
|
||||
|
||||
@@ -42,6 +42,7 @@ 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],
|
||||
|
||||
@@ -79,10 +79,12 @@ 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,
|
||||
@@ -1142,11 +1144,16 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
bool origBold = config.display.heading_bold;
|
||||
config.display.heading_bold = false;
|
||||
|
||||
// Display Region and Channel Utilization
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
if (!config.lora.tx_enabled) {
|
||||
const char *txdisabled = "Transmit Disabled";
|
||||
display->drawString(x, getTextPositions(display)[line], txdisabled);
|
||||
} else {
|
||||
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
char uptimeStr[32] = "";
|
||||
if (currentResolution != ScreenResolution::UltraLow) {
|
||||
|
||||
@@ -6,15 +6,12 @@
|
||||
FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
|
||||
|
||||
Both formats share the same pixel layout and polarity (1 = white, 0 = black).
|
||||
The InkHUD safe-area buffer (944×523) is copied into the centre of the physical
|
||||
The InkHUD safe-area buffer (928×508) is copied into the centre of the physical
|
||||
960×540 FastEPD buffer so content clears the panel's inactive edge border.
|
||||
See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants.
|
||||
|
||||
*/
|
||||
|
||||
// Ruler diagnostic — uncomment to draw calibration lines at each physical edge.
|
||||
// #define EINK_EDGE_LINES
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
#ifdef T5_S3_EPAPER_PRO
|
||||
|
||||
@@ -212,35 +209,6 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
|
||||
memcpy(dstRow, srcRow, srcRowBytes);
|
||||
}
|
||||
|
||||
#ifdef EINK_EDGE_LINES
|
||||
// Draw a 1px black box at the exact boundary of the safe area within the
|
||||
// physical buffer. If the margins are correct, all 4 lines should be
|
||||
// fully visible and right at the edge of the usable display area.
|
||||
|
||||
auto setPixelBlack = [&](uint32_t col, uint32_t row) { cur[row * dstRowBytes + col / 8] &= ~(0x80 >> (col % 8)); };
|
||||
|
||||
const uint32_t safeX = H_OFFSET_BYTES * 8;
|
||||
const uint32_t safeY = V_OFFSET_TOP;
|
||||
const uint32_t safeW = DISPLAY_WIDTH;
|
||||
const uint32_t safeH = DISPLAY_HEIGHT;
|
||||
|
||||
// Top edge: horizontal line at safeY
|
||||
for (uint32_t col = safeX; col < safeX + safeW; col++)
|
||||
setPixelBlack(col, safeY);
|
||||
|
||||
// Bottom edge: horizontal line at safeY + safeH - 1
|
||||
for (uint32_t col = safeX; col < safeX + safeW; col++)
|
||||
setPixelBlack(col, safeY + safeH - 1);
|
||||
|
||||
// Left edge: vertical line at safeX
|
||||
for (uint32_t row = safeY; row < safeY + safeH; row++)
|
||||
setPixelBlack(safeX, row);
|
||||
|
||||
// Right edge: vertical line at safeX + safeW - 1
|
||||
for (uint32_t row = safeY; row < safeY + safeH; row++)
|
||||
setPixelBlack(safeX + safeW - 1, row);
|
||||
#endif
|
||||
|
||||
if (type == FULL) {
|
||||
epaper->fullUpdate(CLEAR_SLOW, false);
|
||||
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
V_OFFSET_TOP and V_OFFSET_BOTTOM (vertical, pixel rows) to position it.
|
||||
|
||||
Changing these constants shifts content inward from each physical edge:
|
||||
H_OFFSET_BYTES = 1 → 8px left margin, 8px right margin (960 – 8 – 8 = 944)
|
||||
V_OFFSET_TOP = 9 → 9px top margin (asymmetric: top ≠ bottom)
|
||||
V_OFFSET_BOTTOM = 8 → 8px bottom margin (540 – 9 – 8 = 523)
|
||||
H_OFFSET_BYTES = 2 → 16px left margin, 16px right margin (960 – 16 – 16 = 928)
|
||||
V_OFFSET_TOP = 16 → 16px top margin
|
||||
V_OFFSET_BOTTOM = 16 → 16px bottom margin (540 – 16 – 16 = 508)
|
||||
|
||||
*/
|
||||
|
||||
@@ -61,13 +61,13 @@ class ED047TC1 : public EInk
|
||||
//
|
||||
// Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible.
|
||||
|
||||
static constexpr uint16_t DISPLAY_WIDTH = 944; // 960 − H_OFFSET_BYTES×8 − right_margin (8+8 = 16px)
|
||||
static constexpr uint16_t DISPLAY_HEIGHT = 523; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (9+8 = 17px)
|
||||
static constexpr uint16_t DISPLAY_WIDTH = 928; // 960 − H_OFFSET_BYTES×8 − right_margin (16+16 = 32px)
|
||||
static constexpr uint16_t DISPLAY_HEIGHT = 508; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (16+16 = 32px)
|
||||
|
||||
static constexpr uint8_t H_OFFSET_BYTES = 1; // visual TOP : 8px physical left margin
|
||||
// visual BOTTOM: 960−8−944=8px physical right margin
|
||||
static constexpr uint8_t V_OFFSET_TOP = 9; // visual RIGHT : CONFIRMED OK
|
||||
static constexpr uint8_t V_OFFSET_BOTTOM = 8; // visual LEFT : 8px physical bottom margin
|
||||
static constexpr uint8_t H_OFFSET_BYTES = 2; // visual TOP : 16px physical left margin
|
||||
// visual BOTTOM: 960−16−928=16px physical right margin
|
||||
static constexpr uint8_t V_OFFSET_TOP = 16; // visual RIGHT : 16px physical top margin
|
||||
static constexpr uint8_t V_OFFSET_BOTTOM = 16; // visual LEFT : 16px physical bottom margin
|
||||
|
||||
static constexpr UpdateTypes supported = static_cast<UpdateTypes>(FULL | FAST);
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ enum MenuAction {
|
||||
SET_REGION_BR_902,
|
||||
SET_REGION_EU_866,
|
||||
SET_REGION_NARROW_868,
|
||||
SET_REGION_ITU1_2M,
|
||||
SET_REGION_ITU2_2M,
|
||||
SET_REGION_ITU3_2M,
|
||||
SET_REGION_ITU2_125CM,
|
||||
// Device Roles
|
||||
SET_ROLE_CLIENT,
|
||||
SET_ROLE_CLIENT_MUTE,
|
||||
@@ -84,6 +88,8 @@ enum MenuAction {
|
||||
SET_PRESET_LITE_FAST,
|
||||
SET_PRESET_NARROW_SLOW,
|
||||
SET_PRESET_NARROW_FAST,
|
||||
SET_PRESET_TINY_SLOW,
|
||||
SET_PRESET_TINY_FAST,
|
||||
SET_PRESET_FROM_REGION, // Dynamic: preset chosen from region-available list
|
||||
// Timezones
|
||||
SET_TZ_US_HAWAII,
|
||||
@@ -123,6 +129,7 @@ enum MenuAction {
|
||||
// Administration
|
||||
RESET_NODEDB_ALL,
|
||||
RESET_NODEDB_KEEP_FAVORITES,
|
||||
WIPE_MESSAGES_ALL,
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "GPS.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "RTC.h"
|
||||
#include "Router.h"
|
||||
#include "airtime.h"
|
||||
@@ -287,7 +288,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
}
|
||||
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
|
||||
changes |= SEGMENT_MODULECONFIG;
|
||||
}
|
||||
// Notify UI that changes are being applied
|
||||
@@ -784,6 +785,22 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU1_2M:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU2_2M:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU3_2M:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU2_125CM:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM);
|
||||
break;
|
||||
|
||||
// Roles
|
||||
case SET_ROLE_CLIENT:
|
||||
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT);
|
||||
@@ -834,6 +851,22 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
applyLoRaPreset(PRESET(SHORT_TURBO));
|
||||
break;
|
||||
|
||||
case SET_PRESET_NARROW_SLOW:
|
||||
applyLoRaPreset(PRESET(NARROW_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_NARROW_FAST:
|
||||
applyLoRaPreset(PRESET(NARROW_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_TINY_SLOW:
|
||||
applyLoRaPreset(PRESET(TINY_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_TINY_FAST:
|
||||
applyLoRaPreset(PRESET(TINY_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_FROM_REGION: {
|
||||
// cursor - 1 because index 0 is "Back"
|
||||
const uint8_t index = cursor - 1;
|
||||
@@ -981,6 +1014,13 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
break;
|
||||
|
||||
case WIPE_MESSAGES_ALL:
|
||||
LOG_INFO("Wiping all messages from menu");
|
||||
messageStore.clearAllMessages();
|
||||
inkhud->persistence->loadLatestMessage();
|
||||
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
|
||||
break;
|
||||
|
||||
default:
|
||||
LOG_WARN("Action not implemented");
|
||||
}
|
||||
@@ -1098,6 +1138,7 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
// Administration Section
|
||||
items.push_back(MenuItem::Header("Administration"));
|
||||
items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
|
||||
items.push_back(MenuItem("Wipe Messages", MenuPage::NODE_CONFIG_ADMIN_MESSAGES));
|
||||
|
||||
// Exit
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
@@ -1469,6 +1510,10 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("KZ 863", MenuAction::SET_REGION_KZ_863, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("NP 865", MenuAction::SET_REGION_NP_865, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("BR 902", MenuAction::SET_REGION_BR_902, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU1_2M (144-146)", MenuAction::SET_REGION_ITU1_2M, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU2_2M (144-148)", MenuAction::SET_REGION_ITU2_2M, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU3_2M (144-148)", MenuAction::SET_REGION_ITU3_2M, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU2_125CM (220-225)", MenuAction::SET_REGION_ITU2_125CM, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
@@ -1498,6 +1543,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
case NODE_CONFIG_ADMIN_MESSAGES:
|
||||
previousPage = MenuPage::NODE_CONFIG;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
items.push_back(MenuItem("Wipe All Messages", MenuAction::WIPE_MESSAGES_ALL, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
// Exit
|
||||
case EXIT:
|
||||
sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
|
||||
|
||||
@@ -36,6 +36,7 @@ enum MenuPage : uint8_t {
|
||||
NODE_CONFIG_BLUETOOTH,
|
||||
NODE_CONFIG_POSITION,
|
||||
NODE_CONFIG_ADMIN_RESET,
|
||||
NODE_CONFIG_ADMIN_MESSAGES,
|
||||
TIMEZONE,
|
||||
APPLETS,
|
||||
AUTOSHOW,
|
||||
|
||||
@@ -22,6 +22,8 @@ void InkHUD::Persistence::loadSettings()
|
||||
// are immediately available to applets (DMApplet, AllMessageApplet, NotificationApplet).
|
||||
void InkHUD::Persistence::loadLatestMessage()
|
||||
{
|
||||
latestMessage = LatestMessage();
|
||||
|
||||
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
|
||||
for (const StoredMessage &m : messageStore.getLiveMessages()) {
|
||||
if (m.type == MessageType::BROADCAST) {
|
||||
@@ -75,4 +77,4 @@ void InkHUD::Persistence::printSettings(Settings *settings)
|
||||
}
|
||||
*/
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#include "configuration.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
#include "security/LockdownDisplay.h"
|
||||
#endif
|
||||
|
||||
#if ARCH_PORTDUINO
|
||||
#include "input/LinuxInputImpl.h"
|
||||
@@ -122,6 +125,22 @@ int InputBroker::handleInputEvent(const InputEvent *event)
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// Lockdown: when the display is redacted (storage locked, or screen-lock
|
||||
// latch set after idle) the screen content is hidden, but local input
|
||||
// would otherwise still flow into UI handlers — letting an operator
|
||||
// drive menus, fire canned messages, change settings etc. blind. Eat
|
||||
// the event here so input is no-op until the redaction clears.
|
||||
// The latch is cleared only by unlockScreen() on a successful
|
||||
// passphrase auth (see PhoneAPI::handleLockdownAuthInline) — local
|
||||
// input does not clear it, even if storage happens to be unlocked.
|
||||
// PowerFSM was already triggered above, so the backlight still wakes
|
||||
// to show the LOCKED frame — the input just doesn't act on anything.
|
||||
if (meshtastic_security::shouldRedactDisplay()) {
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
this->notifyObservers(event);
|
||||
return 0;
|
||||
}
|
||||
|
||||
+144
-2
@@ -68,6 +68,19 @@ void nrf54l15Loop();
|
||||
NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr;
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_ENABLE_APPROTECT
|
||||
#include "security/APProtect.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
#include "mesh/PhoneAPI.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
#include "security/LockdownDisplay.h"
|
||||
#endif
|
||||
|
||||
#if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D)
|
||||
#include "mesh/api/WiFiServerAPI.h"
|
||||
#include "mesh/wifi/WiFiAPClient.h"
|
||||
@@ -379,6 +392,14 @@ void setup()
|
||||
consoleInit(); // Set serial baud rate and init our mesh console
|
||||
#endif
|
||||
|
||||
// M23 (audit): APPROTECT engagement moved below fsInit() so we can gate
|
||||
// on EncryptedStorage::isProvisioned(). Engaging on an unprovisioned dev
|
||||
// board permanently locks SWD before the operator has even set a
|
||||
// passphrase — a misconfigured CI build flashed to a developer device
|
||||
// would brick its debug port on first boot. Now we only engage when the
|
||||
// device has a DEK file on flash, i.e. the operator has explicitly
|
||||
// committed to lockdown via passphrase provisioning.
|
||||
|
||||
#ifdef UNPHONE
|
||||
unphone.printStore();
|
||||
#endif
|
||||
@@ -409,7 +430,12 @@ void setup()
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(DEBUG_MUTE) && defined(DEBUG_PORT)
|
||||
// The DEBUG_MUTE "we are muted, FYI" banner spills APP_VERSION / APP_ENV /
|
||||
// APP_REPO out the USB CDC even with logging otherwise suppressed — a free
|
||||
// firmware-fingerprinting primitive for an attacker holding the cable.
|
||||
// Under MESHTASTIC_LOCKDOWN we want the device to look uniformly silent
|
||||
// until the operator authenticates, so skip the banner entirely there.
|
||||
#if defined(DEBUG_MUTE) && defined(DEBUG_PORT) && !defined(MESHTASTIC_LOCKDOWN)
|
||||
DEBUG_PORT.printf("\r\n\r\n//\\ E S H T /\\ S T / C\r\n");
|
||||
DEBUG_PORT.printf("Version %s for %s from %s\r\n", optstr(APP_VERSION), optstr(APP_ENV), optstr(APP_REPO));
|
||||
DEBUG_PORT.printf("Debug mute is enabled, there will be no serial output.\r\n");
|
||||
@@ -481,6 +507,38 @@ void setup()
|
||||
|
||||
fsInit();
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
EncryptedStorage::initLocked();
|
||||
if (!EncryptedStorage::isUnlocked()) {
|
||||
if (!EncryptedStorage::isProvisioned()) {
|
||||
LOG_WARN("Lockdown: Device not provisioned — connect and set a passphrase to unlock storage");
|
||||
} else {
|
||||
LOG_WARN("Lockdown: Device locked — connect and provide passphrase to unlock storage");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(MESHTASTIC_ENABLE_APPROTECT) && defined(MESHTASTIC_ENCRYPTED_STORAGE)
|
||||
// M23 (audit): only engage the irreversible UICR APPROTECT lockout once
|
||||
// the device has been provisioned with a passphrase. A misconfigured
|
||||
// CI build of a lockdown variant flashed to a developer board would
|
||||
// otherwise burn SWD on first boot before the operator has even set a
|
||||
// passphrase, taking the board out of the dev/recovery workflow with
|
||||
// no real security benefit (there's no DEK to protect yet). Once a
|
||||
// DEK file exists, the operator has committed to lockdown — engaging
|
||||
// APPROTECT then is the protection they asked for.
|
||||
if (EncryptedStorage::isProvisioned()) {
|
||||
enableAPProtect();
|
||||
} else {
|
||||
LOG_INFO("APPROTECT deferred: device not yet provisioned");
|
||||
}
|
||||
#elif defined(MESHTASTIC_ENABLE_APPROTECT)
|
||||
// Lockdown without encrypted storage shouldn't be reachable per
|
||||
// configuration.h, but if it ever is, fall back to the unconditional
|
||||
// engagement.
|
||||
enableAPProtect();
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_I2C
|
||||
#if defined(I2C_SDA1) && defined(ARCH_RP2040)
|
||||
Wire1.setSDA(I2C_SDA1);
|
||||
@@ -1077,6 +1135,11 @@ uint32_t rebootAtMsec; // If not zero we will reboot at this time (used to r
|
||||
uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to shutdown from python or mobile client)
|
||||
bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for OTA handoff)
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
volatile bool lockdownReloadPending; // see main.h — deferred NodeDB reload after lockdown unlock
|
||||
volatile bool lockdownDisablePending; // see main.h — deferred decrypt-revert after lockdown disable
|
||||
#endif
|
||||
|
||||
// If a thread does something that might need for it to be rescheduled ASAP it can set this flag
|
||||
// This will suppress the current delay and instead try to run ASAP.
|
||||
bool runASAP;
|
||||
@@ -1128,7 +1191,7 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
|
||||
// No bluetooth on these targets (yet):
|
||||
// Pico W / 2W may get it at some point
|
||||
// Portduino and ESP32-C6 are excluded because we don't have a working bluetooth stacks integrated yet.
|
||||
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL) || defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_BLUETOOTH_CONFIG;
|
||||
#endif
|
||||
|
||||
@@ -1161,6 +1224,85 @@ void loop()
|
||||
{
|
||||
runASAP = false;
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
if (lockdownDisablePending) {
|
||||
lockdownDisablePending = false;
|
||||
LOG_INFO("Lockdown: disabling — reverting encrypted storage to plaintext");
|
||||
if (nodeDB->disableLockdownToPlaintext()) {
|
||||
LOG_INFO("Lockdown: disabled, rebooting into normal mode");
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
} else {
|
||||
// Revert failed mid-way (a file couldn't be decrypted/rewritten).
|
||||
// The DEK file is still present (it's deleted last), so the device
|
||||
// stays in lockdown and the operator can retry disable. Surface
|
||||
// the failure rather than leaving the client hanging.
|
||||
LOG_ERROR("Lockdown: disable revert failed — device remains in lockdown");
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "disable_failed", 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (lockdownReloadPending) {
|
||||
lockdownReloadPending = false;
|
||||
LOG_INFO("Lockdown: reloading config from disk after unlock");
|
||||
bool reloadOk = nodeDB->reloadFromDisk();
|
||||
if (!reloadOk) {
|
||||
// Storage decrypt/decode failed during reload. Treat as
|
||||
// unrecoverable for this boot: lock storage, revoke any
|
||||
// auth that managed to slip through (defense in depth — the
|
||||
// cold-unlock path doesn't authorize until completion, but
|
||||
// a concurrent re-verify-path call from another connection
|
||||
// might have), and notify clients. Storage will be locked
|
||||
// on next boot anyway; deferring to the user-visible
|
||||
// notification path is sufficient for now.
|
||||
LOG_ERROR("Lockdown: reload failed — locking and notifying clients");
|
||||
EncryptedStorage::lockNow();
|
||||
PhoneAPI::revokeAllAuth();
|
||||
}
|
||||
PhoneAPI::completePendingUnlocks(reloadOk);
|
||||
}
|
||||
|
||||
// Periodic session-expiry check. Cheap — millis() comparison. Don't
|
||||
// hammer it every loop tick; once a second is plenty.
|
||||
static uint32_t lastSessionCheckMs = 0;
|
||||
if (millis() - lastSessionCheckMs > 1000) {
|
||||
lastSessionCheckMs = millis();
|
||||
if (rebootAtMsec == 0 && EncryptedStorage::isUnlocked() && EncryptedStorage::isSessionExpired()) {
|
||||
// The session expired. Two paths:
|
||||
// 1. Budget remains (bootsRemaining > 0): decrement the
|
||||
// on-flash boot count in place, revoke per-connection
|
||||
// auth, re-engage screen redaction, re-arm the uptime
|
||||
// timer — all WITHOUT rebooting. Storage stays unlocked
|
||||
// so the mesh keeps routing. Clients must re-authenticate
|
||||
// to see content again. The decrement is what enforces
|
||||
// the rollback ceiling — bootsRemaining ticks down
|
||||
// monotonically whether the device reboots or not.
|
||||
// 2. Budget exhausted (bootsRemaining == 0): no more
|
||||
// sessions to grant. Hard lock (token deleted, DEK
|
||||
// zeroed) and reboot. Operator must re-enter passphrase.
|
||||
if (EncryptedStorage::getBootsRemaining() == 0) {
|
||||
LOG_WARN("Lockdown: session limit reached and boot budget exhausted, locking and rebooting");
|
||||
EncryptedStorage::lockNow();
|
||||
PhoneAPI::revokeAllAuth();
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "session_budget_exhausted", 0, 0, 0);
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
} else {
|
||||
uint8_t newBoots = EncryptedStorage::consumeSessionBoot();
|
||||
LOG_WARN("Lockdown: session expired, rolled to next budget slot (boots=%u remaining)", newBoots);
|
||||
PhoneAPI::revokeAllAuth();
|
||||
meshtastic_security::lockScreen();
|
||||
// Signal clients that they need to re-auth on this
|
||||
// connection. Storage is still unlocked (DEK in RAM,
|
||||
// mesh keeps routing) but per-connection auth is gone.
|
||||
// Reusing the LOCKED(needs_auth) post-config emission
|
||||
// pattern so existing clients don't need a new state.
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "needs_auth", newBoots,
|
||||
EncryptedStorage::getValidUntilEpoch(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
esp32Loop();
|
||||
#endif
|
||||
|
||||
+13
@@ -92,6 +92,19 @@ extern uint32_t rebootAtMsec;
|
||||
extern uint32_t shutdownAtMsec;
|
||||
extern bool suppressRebootBanner;
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
// Set by PhoneAPI::handleLockdownAuthInline after a successful unlock.
|
||||
// Serviced on the main loop thread because NodeDB::reloadFromDisk() is
|
||||
// too heavy for the BLE/serial transport callback stack.
|
||||
extern volatile bool lockdownReloadPending;
|
||||
|
||||
// Set by PhoneAPI::handleLockdownAuthInline on a disable request (after the
|
||||
// passphrase is verified). Serviced on the main loop thread: decrypt every
|
||||
// pref back to plaintext, remove the lockdown artifacts, reboot. Heavy file
|
||||
// IO, same reason as lockdownReloadPending.
|
||||
extern volatile bool lockdownDisablePending;
|
||||
#endif
|
||||
|
||||
extern uint32_t serialSinceMsec;
|
||||
|
||||
// If a thread does something that might need for it to be rescheduled ASAP it can set this flag
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
#include <malloc.h>
|
||||
#include <unistd.h> // sbrk
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
#if defined(ARCH_STM32)
|
||||
// Returns the uncommitted sbrk headroom: addressable space between the current heap
|
||||
// break and the stack pointer that has not yet been committed to the arena.
|
||||
static uint32_t sbrkHeadroom()
|
||||
|
||||
@@ -404,6 +404,42 @@ bool Channels::isDefaultChannel(ChannelIndex chIndex)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cryptoKeyIsPublic(const CryptoKey &key)
|
||||
{
|
||||
if (key.length == 0)
|
||||
return true; // encryption disabled
|
||||
// Match the defaultpsk family ignoring its last byte (getKey() bumps only that byte per 1-byte index).
|
||||
if (key.length == (int)sizeof(defaultpsk) && memcmp(key.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Channels::usesPublicKey(ChannelIndex chIndex)
|
||||
{
|
||||
const meshtastic_Channel &ch = getByIndex(chIndex);
|
||||
if (!ch.has_settings || ch.role == meshtastic_Channel_Role_DISABLED)
|
||||
return false;
|
||||
|
||||
const auto &psk = ch.settings.psk;
|
||||
if (psk.size == 0) {
|
||||
// Secondary channels inherit the primary key when unset; primary size==0 means encryption disabled.
|
||||
if (ch.role == meshtastic_Channel_Role_SECONDARY) {
|
||||
// Guard against malformed configs with no PRIMARY channel (primaryIndex could point back to us).
|
||||
if (primaryIndex == chIndex)
|
||||
return true; // fail closed: treat as public
|
||||
return usesPublicKey(primaryIndex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (psk.size == 1) {
|
||||
// Short PSK aliases: 0 disables encryption; 1..255 are the public defaultpsk family.
|
||||
return true;
|
||||
}
|
||||
|
||||
return (psk.size == sizeof(defaultpsk) && memcmp(psk.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0);
|
||||
}
|
||||
|
||||
bool Channels::hasDefaultChannel()
|
||||
{
|
||||
// If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel
|
||||
|
||||
@@ -86,6 +86,9 @@ class Channels
|
||||
// Returns true if the channel has the default name and PSK
|
||||
bool isDefaultChannel(ChannelIndex chIndex);
|
||||
|
||||
// Returns true if this channel's effective key is publicly decryptable (open or well-known/default PSK).
|
||||
bool usesPublicKey(ChannelIndex chIndex);
|
||||
|
||||
// Returns true if we can be reached via a channel with the default settings given a region and modem preset
|
||||
bool hasDefaultChannel();
|
||||
|
||||
@@ -144,6 +147,9 @@ extern Channels channels;
|
||||
static const uint8_t defaultpsk[] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
|
||||
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01};
|
||||
|
||||
/// True if a getKey()-resolved key offers no privacy: length 0 (off) or the public defaultpsk family. Pure; for tests.
|
||||
bool cryptoKeyIsPublic(const CryptoKey &key);
|
||||
|
||||
static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36,
|
||||
0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74,
|
||||
0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1};
|
||||
+108
-3
@@ -12,10 +12,18 @@
|
||||
#include <Curve25519.h>
|
||||
#include <RNG.h>
|
||||
#include <SHA256.h>
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
|
||||
#if !defined(ARCH_STM32WL)
|
||||
#define CryptRNG RNG
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
#include "XEdDSA.h"
|
||||
#include <Ed25519.h>
|
||||
|
||||
#ifndef NUM_LIMBS_256BIT
|
||||
#define NUM_LIMBS_BITS(n) (((n) + sizeof(limb_t) * 8 - 1) / (8 * sizeof(limb_t)))
|
||||
#define NUM_LIMBS_256BIT NUM_LIMBS_BITS(256)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
|
||||
|
||||
/**
|
||||
* Create a public/private key pair with Curve25519.
|
||||
@@ -46,6 +54,9 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey)
|
||||
Curve25519::dh1(public_key, private_key);
|
||||
memcpy(pubKey, public_key, sizeof(public_key));
|
||||
memcpy(privKey, private_key, sizeof(private_key));
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +76,9 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
|
||||
}
|
||||
memcpy(private_key, privKey, sizeof(private_key));
|
||||
memcpy(public_key, pubKey, sizeof(public_key));
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
|
||||
#endif
|
||||
} else {
|
||||
LOG_WARN("X25519 key generation failed due to blank private key");
|
||||
return false;
|
||||
@@ -72,6 +86,97 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
/**
|
||||
* Build a signing buffer that covers packet metadata and payload:
|
||||
* [fromNode(4) | packetId(4) | portnum(4) | payload(N)]
|
||||
* This prevents replay, reattribution, and portnum redirection attacks.
|
||||
*/
|
||||
static size_t buildSigningBuffer(uint8_t *buf, size_t bufSize, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
|
||||
const uint8_t *payload, size_t payloadLen)
|
||||
{
|
||||
const size_t headerLen = sizeof(uint32_t) * 3;
|
||||
size_t totalLen = headerLen + payloadLen;
|
||||
if (totalLen > bufSize)
|
||||
return 0;
|
||||
// May need endian conversion for oddball platforms.
|
||||
memcpy(buf, &fromNode, sizeof(uint32_t));
|
||||
memcpy(buf + sizeof(uint32_t), &packetId, sizeof(uint32_t));
|
||||
memcpy(buf + sizeof(uint32_t) * 2, &portnum, sizeof(uint32_t));
|
||||
memcpy(buf + headerLen, payload, payloadLen);
|
||||
return totalLen;
|
||||
}
|
||||
|
||||
bool CryptoEngine::xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
|
||||
uint8_t *signature)
|
||||
{
|
||||
if (memfll(xeddsa_private_key, 0, sizeof(xeddsa_private_key)))
|
||||
return false;
|
||||
uint8_t sigBuf[MAX_BLOCKSIZE];
|
||||
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
|
||||
if (sigLen == 0)
|
||||
return false;
|
||||
// the XEdDSA::sign function requires at least the first 32 bytes of signature to be pre-filled with randomness
|
||||
HardwareRNG::fill(signature, 32);
|
||||
XEdDSA::sign(signature, xeddsa_private_key, xeddsa_public_key, sigBuf, sigLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CryptoEngine::xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
|
||||
const uint8_t *payload, size_t payloadLen, const uint8_t *signature)
|
||||
{
|
||||
// Use cached Ed25519 key if the Curve25519 key matches, avoiding expensive field inversion
|
||||
if (memcmp(pubKey, cached_curve_pubkey, 32) != 0) {
|
||||
curve_to_ed_pub(pubKey, cached_ed_pubkey);
|
||||
memcpy(cached_curve_pubkey, pubKey, 32);
|
||||
}
|
||||
uint8_t sigBuf[MAX_BLOCKSIZE];
|
||||
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
|
||||
if (sigLen == 0)
|
||||
return false;
|
||||
return XEdDSA::verify(signature, cached_ed_pubkey, sigBuf, sigLen);
|
||||
}
|
||||
|
||||
void CryptoEngine::curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey)
|
||||
{
|
||||
|
||||
// Apply the birational map defined in RFC 7748, section 4.1 "Curve25519" to calculate an Ed25519 public
|
||||
// key from a Curve25519 public key. Because the serialization format of Curve25519 public keys only
|
||||
// contains the u coordinate, the x coordinate of the corresponding Ed25519 public key can't be uniquely
|
||||
// calculated as defined by the birational map. The x coordinate is represented in the serialization
|
||||
// format of Ed25519 public keys only in a single sign bit. XEdDSA always normalizes the Ed25519 public
|
||||
// key to a sign bit of zero (the signer negates its key pair when needed), so this function clears the
|
||||
// sign bit unconditionally below instead of taking it as an input.
|
||||
fe u, y;
|
||||
fe one;
|
||||
fe u_minus_one, u_plus_one, u_plus_one_inv;
|
||||
|
||||
// Parse the Curve25519 public key input as a field element containing the u coordinate. RFC 7748,
|
||||
// section 5 "The X25519 and X448 Functions", mandates that the most significant bit of the Curve25519
|
||||
// public key has to be zeroized. This is handled by fe_frombytes internally.
|
||||
fe_frombytes(u, curve_pubkey);
|
||||
|
||||
// Calculate the parameters (u - 1) and (u + 1)
|
||||
fe_1(one);
|
||||
fe_sub(u_minus_one, u, one);
|
||||
fe_add(u_plus_one, u, one);
|
||||
|
||||
// Invert u + 1
|
||||
fe_invert(u_plus_one_inv, u_plus_one);
|
||||
|
||||
// Calculate y = (u - 1) * inv(u + 1) (mod p)
|
||||
fe_mul(y, u_minus_one, u_plus_one_inv);
|
||||
|
||||
// Serialize the field element containing the y coordinate to the Ed25519 public key output
|
||||
fe_tobytes(ed_pubkey, y);
|
||||
|
||||
// Set the sign bit to zero
|
||||
ed_pubkey[31] &= 0x7f;
|
||||
|
||||
// need to convert the pubkey y = ( u - 1) * inv( u + 1) (mod p).
|
||||
}
|
||||
#endif
|
||||
|
||||
bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user)
|
||||
{
|
||||
if (user.is_licensed) {
|
||||
|
||||
+15
-1
@@ -23,6 +23,7 @@ struct CryptoKey {
|
||||
|
||||
#define MAX_BLOCKSIZE 256
|
||||
#define TEST_CURVE25519_FIELD_OPS // Exposes Curve25519::isWeakPoint() for testing keys
|
||||
#define XEDDSA_SIGNATURE_SIZE 64
|
||||
|
||||
class CryptoEngine
|
||||
{
|
||||
@@ -37,7 +38,12 @@ class CryptoEngine
|
||||
virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user);
|
||||
|
||||
#endif
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
bool xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
|
||||
uint8_t *signature);
|
||||
bool xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload,
|
||||
size_t payloadLen, const uint8_t *signature);
|
||||
#endif
|
||||
void setDHPrivateKey(uint8_t *_private_key);
|
||||
// The remotePublic key parameter takes the public_key bytes container from
|
||||
@@ -85,6 +91,14 @@ class CryptoEngine
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
uint8_t shared_key[32] = {0};
|
||||
uint8_t private_key[32] = {0};
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
uint8_t xeddsa_public_key[32] = {0};
|
||||
uint8_t xeddsa_private_key[32] = {0};
|
||||
void curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey);
|
||||
// Single-entry cache for curve_to_ed_pub conversion (avoids expensive field inversion per packet)
|
||||
uint8_t cached_curve_pubkey[32] = {0};
|
||||
uint8_t cached_ed_pubkey[32] = {0};
|
||||
#endif
|
||||
#endif
|
||||
/**
|
||||
* Init our 128 bit nonce for a new packet
|
||||
|
||||
@@ -37,6 +37,12 @@ enum class TrafficType { POSITION, TELEMETRY };
|
||||
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
|
||||
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
|
||||
|
||||
// Hop scaling defaults
|
||||
#define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count
|
||||
#define default_hop_scaling_max_target_nodes 80 // generous extension ceiling (2 × min)
|
||||
#define default_hop_scaling_min_target_nodes_floor 5 // minimum allowed min_target_nodes
|
||||
#define default_hop_scaling_max_target_nodes_ceiling 512 // maximum allowed max_target_nodes
|
||||
|
||||
#ifdef USERPREFS_RINGTONE_NAG_SECS
|
||||
#define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS
|
||||
#else
|
||||
|
||||
+110
-16
@@ -2,14 +2,66 @@
|
||||
#include "LoRaFEMInterface.h"
|
||||
|
||||
#if defined(ARCH_ESP32)
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/rtc_io.h>
|
||||
#include <esp_sleep.h>
|
||||
#endif
|
||||
|
||||
LoRaFEMInterface loraFEMInterface;
|
||||
|
||||
static void enableFEMPower()
|
||||
{
|
||||
bool wasOff = digitalRead(LORA_PA_POWER) != HIGH;
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
if (wasOff) {
|
||||
delay(5); // This is an arbitrary 5ms for FEM rail power-up.
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(ARCH_ESP32)
|
||||
static void releasePinHold(int pin)
|
||||
{
|
||||
if (pin < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
gpio_num_t gpio = (gpio_num_t)pin;
|
||||
|
||||
#if SOC_RTCIO_HOLD_SUPPORTED
|
||||
if (rtc_gpio_is_valid_gpio(gpio)) {
|
||||
rtc_gpio_hold_dis(gpio);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (GPIO_IS_VALID_OUTPUT_GPIO(gpio)) {
|
||||
gpio_hold_dis(gpio);
|
||||
}
|
||||
}
|
||||
|
||||
static void releaseSleepHolds()
|
||||
{
|
||||
releasePinHold(LORA_PA_POWER);
|
||||
#ifdef HELTEC_V4
|
||||
releasePinHold(LORA_KCT8103L_PA_CSD);
|
||||
releasePinHold(LORA_KCT8103L_PA_CTX);
|
||||
#elif defined(USE_GC1109_PA)
|
||||
releasePinHold(LORA_GC1109_PA_EN);
|
||||
releasePinHold(LORA_GC1109_PA_TX_EN);
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
releasePinHold(LORA_KCT8103L_PA_CSD);
|
||||
releasePinHold(LORA_KCT8103L_PA_CTX);
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void LoRaFEMInterface::init(void)
|
||||
{
|
||||
setLnaCanControl(false); // Default is uncontrollable
|
||||
#if defined(RF_PA_DETECT_PIN)
|
||||
pinMode(RF_PA_DETECT_PIN, INPUT);
|
||||
high_power_pa = (digitalRead(RF_PA_DETECT_PIN) == RF_PA_HIGH_POWER_VALUE);
|
||||
LOG_INFO("Detected %s LoRa PA profile", high_power_pa ? "high-power" : "low-power");
|
||||
#endif
|
||||
#ifdef HELTEC_V4
|
||||
pinMode(LORA_PA_POWER, OUTPUT);
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
@@ -21,6 +73,7 @@ void LoRaFEMInterface::init(void)
|
||||
if (digitalRead(LORA_KCT8103L_PA_CSD) == HIGH) {
|
||||
// FEM is KCT8103L
|
||||
fem_type = KCT8103L_PA;
|
||||
LOG_INFO("Detected KCT8103L LoRa FEM");
|
||||
rtc_gpio_hold_dis((gpio_num_t)LORA_KCT8103L_PA_CTX);
|
||||
pinMode(LORA_KCT8103L_PA_CSD, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
@@ -30,6 +83,7 @@ void LoRaFEMInterface::init(void)
|
||||
} else if (digitalRead(LORA_KCT8103L_PA_CSD) == LOW) {
|
||||
// FEM is GC1109
|
||||
fem_type = GC1109_PA;
|
||||
LOG_INFO("Detected GC1109 LoRa FEM");
|
||||
// LORA_GC1109_PA_EN and LORA_KCT8103L_PA_CSD are the same pin and do not need to be repeatedly turned off and held.
|
||||
// rtc_gpio_hold_dis((gpio_num_t)LORA_GC1109_PA_EN);
|
||||
pinMode(LORA_GC1109_PA_EN, OUTPUT);
|
||||
@@ -41,6 +95,7 @@ void LoRaFEMInterface::init(void)
|
||||
}
|
||||
#elif defined(USE_GC1109_PA)
|
||||
fem_type = GC1109_PA;
|
||||
LOG_INFO("Using GC1109 LoRa FEM");
|
||||
pinMode(LORA_PA_POWER, OUTPUT);
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
#if defined(ARCH_ESP32)
|
||||
@@ -55,6 +110,7 @@ void LoRaFEMInterface::init(void)
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, LOW);
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
fem_type = KCT8103L_PA;
|
||||
LOG_INFO("Using KCT8103L LoRa FEM");
|
||||
pinMode(LORA_PA_POWER, OUTPUT);
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
#if defined(ARCH_ESP32)
|
||||
@@ -68,11 +124,22 @@ void LoRaFEMInterface::init(void)
|
||||
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
|
||||
setLnaCanControl(true);
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
fem_type = KCT8103L_PA;
|
||||
pinMode(LORA_KCT8103L_EN, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_EN, HIGH);
|
||||
delay(1);
|
||||
pinMode(LORA_KCT8103L_TX_RX, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LoRaFEMInterface::setSleepModeEnable(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
if (fem_type == GC1109_PA) {
|
||||
/*
|
||||
@@ -84,6 +151,7 @@ void LoRaFEMInterface::setSleepModeEnable(void)
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
// shutdown the PA
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, LOW);
|
||||
digitalWrite(LORA_PA_POWER, LOW);
|
||||
}
|
||||
#elif defined(USE_GC1109_PA)
|
||||
digitalWrite(LORA_GC1109_PA_EN, LOW);
|
||||
@@ -91,16 +159,25 @@ void LoRaFEMInterface::setSleepModeEnable(void)
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
// shutdown the PA
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, LOW);
|
||||
digitalWrite(LORA_PA_POWER, LOW);
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
// shutdown the PA
|
||||
digitalWrite(LORA_KCT8103L_EN, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LoRaFEMInterface::setTxModeEnable(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
if (fem_type == GC1109_PA) {
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, HIGH); // CPS: 1=full PA, 0=bypass (for RX, CPS is don't care)
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
@@ -108,18 +185,27 @@ void LoRaFEMInterface::setTxModeEnable(void)
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, HIGH); // CPS: 1=full PA, 0=bypass (for RX, CPS is don't care)
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, HIGH);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LoRaFEMInterface::setRxModeEnable(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
if (fem_type == GC1109_PA) {
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, LOW);
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
@@ -131,23 +217,29 @@ void LoRaFEMInterface::setRxModeEnable(void)
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, LOW);
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
} else {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
// Keep GC1109 FEM powered during deep sleep so LNA remains active for RX wake.
|
||||
// Set PA_POWER and PA_EN HIGH (overrides SX126xInterface::sleep() shutdown),
|
||||
// then latch with RTC hold so the state survives deep sleep.
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
// Keep FEM rail powered during deep sleep so LoRa RX wake can work (GC1109 keeps LNA active; KCT8103L uses RX bypass).
|
||||
// Set PA_POWER HIGH (overrides SX126xInterface::sleep() shutdown), then latch with RTC hold so the state survives deep sleep.
|
||||
enableFEMPower();
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_PA_POWER);
|
||||
if (fem_type == GC1109_PA) {
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH);
|
||||
@@ -156,15 +248,11 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
} else {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); // RX bypass while MCU sleeps
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
|
||||
}
|
||||
#elif defined(USE_GC1109_PA)
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH);
|
||||
#if defined(ARCH_ESP32)
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_PA_POWER);
|
||||
@@ -172,16 +260,17 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
|
||||
gpio_pulldown_en((gpio_num_t)LORA_GC1109_PA_TX_EN);
|
||||
#endif
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
} else {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); // RX bypass while MCU sleeps
|
||||
#if defined(ARCH_ESP32)
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_PA_POWER);
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
|
||||
#endif
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -192,6 +281,11 @@ void LoRaFEMInterface::setLNAEnable(bool enabled)
|
||||
|
||||
int8_t LoRaFEMInterface::powerConversion(int8_t loraOutputPower)
|
||||
{
|
||||
#if defined(RF_PA_DETECT_PIN)
|
||||
if (!high_power_pa) {
|
||||
return loraOutputPower;
|
||||
}
|
||||
#endif
|
||||
#ifdef HELTEC_V4
|
||||
const uint16_t gc1109_tx_gain[] = {11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 9, 9, 8, 7};
|
||||
const uint16_t kct8103l_tx_gain[] = {13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7};
|
||||
@@ -227,4 +321,4 @@ int8_t LoRaFEMInterface::powerConversion(int8_t loraOutputPower)
|
||||
return loraOutputPower;
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -24,7 +24,8 @@ class LoRaFEMInterface
|
||||
LoRaFEMType fem_type;
|
||||
bool lna_enabled = true;
|
||||
bool lna_can_control = false;
|
||||
bool high_power_pa = true;
|
||||
};
|
||||
extern LoRaFEMInterface loraFEMInterface;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
+41
-1
@@ -44,7 +44,8 @@ extern const RegionProfile PROFILE_EU868;
|
||||
extern const RegionProfile PROFILE_UNDEF;
|
||||
extern const RegionProfile PROFILE_LITE;
|
||||
extern const RegionProfile PROFILE_NARROW;
|
||||
// extern const RegionProfile PROFILE_HAM;
|
||||
extern const RegionProfile PROFILE_HAM_20KHZ;
|
||||
extern const RegionProfile PROFILE_HAM_100KHZ;
|
||||
|
||||
// Map from old region names to new region enums
|
||||
struct RegionInfo {
|
||||
@@ -64,6 +65,14 @@ struct RegionInfo {
|
||||
// Preset accessors (delegate through profile)
|
||||
meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; }
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; }
|
||||
bool supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) const
|
||||
{
|
||||
for (size_t i = 0; profile->presets[i] != MODEM_PRESET_END; i++) {
|
||||
if (profile->presets[i] == preset)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
size_t getNumPresets() const
|
||||
{
|
||||
size_t n = 0;
|
||||
@@ -77,6 +86,7 @@ extern const RegionInfo regions[];
|
||||
extern const RegionInfo *myRegion;
|
||||
|
||||
extern void initRegion();
|
||||
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
|
||||
|
||||
// Valid LoRa spread factor range and defaults
|
||||
constexpr uint8_t LORA_SF_MIN = 5;
|
||||
@@ -123,8 +133,18 @@ static inline float clampBandwidthKHz(float bwKHz)
|
||||
|
||||
static inline float bwCodeToKHz(uint16_t bwCode)
|
||||
{
|
||||
if (bwCode == 8)
|
||||
return 7.8f;
|
||||
if (bwCode == 10)
|
||||
return 10.4f;
|
||||
if (bwCode == 16)
|
||||
return 15.6f;
|
||||
if (bwCode == 21)
|
||||
return 20.8f;
|
||||
if (bwCode == 31)
|
||||
return 31.25f;
|
||||
if (bwCode == 42)
|
||||
return 41.7f;
|
||||
if (bwCode == 62)
|
||||
return 62.5f;
|
||||
if (bwCode == 200)
|
||||
@@ -140,8 +160,18 @@ static inline float bwCodeToKHz(uint16_t bwCode)
|
||||
|
||||
static inline uint16_t bwKHzToCode(float bwKHz)
|
||||
{
|
||||
if (bwKHz > 7.7f && bwKHz < 7.9f)
|
||||
return 8;
|
||||
if (bwKHz > 10.3f && bwKHz < 10.5f)
|
||||
return 10;
|
||||
if (bwKHz > 15.5f && bwKHz < 15.7f)
|
||||
return 16;
|
||||
if (bwKHz > 20.7f && bwKHz < 20.9f)
|
||||
return 21;
|
||||
if (bwKHz > 31.24f && bwKHz < 31.26f)
|
||||
return 31;
|
||||
if (bwKHz > 41.6f && bwKHz < 41.8f)
|
||||
return 42;
|
||||
if (bwKHz > 62.49f && bwKHz < 62.51f)
|
||||
return 62;
|
||||
if (bwKHz > 203.12f && bwKHz < 203.13f)
|
||||
@@ -219,6 +249,16 @@ static inline void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset
|
||||
cr = 6;
|
||||
sf = 8;
|
||||
break;
|
||||
case PRESET(TINY_FAST):
|
||||
bwKHz = 15.6f;
|
||||
cr = 5;
|
||||
sf = 7;
|
||||
break;
|
||||
case PRESET(TINY_SLOW):
|
||||
bwKHz = 15.6f;
|
||||
cr = 6;
|
||||
sf = 8;
|
||||
break;
|
||||
default: // LONG_FAST (or illegal)
|
||||
bwKHz = wideLora ? 812.5f : 250.0f;
|
||||
cr = 5;
|
||||
|
||||
@@ -141,6 +141,12 @@ class MeshService
|
||||
/// Release the next ClientNotification packet to pool.
|
||||
void releaseClientNotificationToPool(meshtastic_ClientNotification *p) { clientNotificationPool.release(p); }
|
||||
|
||||
/// Bump fromNum to signal connected clients to poll for new FromRadio data.
|
||||
/// Used by code paths (e.g. lockdown status queueing) that surface a new
|
||||
/// FromRadio variant without going through one of the existing pool-backed
|
||||
/// senders.
|
||||
void nudgeFromNum() { fromNum++; }
|
||||
|
||||
/**
|
||||
* Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh)
|
||||
* Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep
|
||||
|
||||
+821
-53
File diff suppressed because it is too large
Load Diff
+122
-10
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "MeshTypes.h"
|
||||
#include "NodeStatus.h"
|
||||
#include "WarmNodeStore.h"
|
||||
#include "concurrency/Lock.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
@@ -226,9 +227,25 @@ class NodeDB
|
||||
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0);
|
||||
|
||||
/*
|
||||
* Sets a node either favorite or unfavorite
|
||||
* Sets a node either favorite or unfavorite. Returns true if the node ends
|
||||
* up in the requested state; false if the node is unknown or favouriting
|
||||
* was refused by the protected-node cap (MAX_NUM_NODES - 2).
|
||||
*/
|
||||
void set_favorite(bool is_favorite, uint32_t nodeId);
|
||||
bool set_favorite(bool is_favorite, uint32_t nodeId);
|
||||
|
||||
/// Count of eviction-protected (favourite/ignored/manually-verified) nodes.
|
||||
int numProtectedNodes() const;
|
||||
|
||||
/// printf-style warning emitted when setProtectedFlag() refuses a node at
|
||||
/// the cap. %s = verb (favorite/ignore), 0x%08x = node, %d = cap. Shared by
|
||||
/// LOG_WARN here and AdminModule::sendWarning so the wording stays in sync.
|
||||
static constexpr const char *PROTECTED_CAP_WARN_FMT = "Can't %s 0x%08x: protected-node limit (%d) reached";
|
||||
|
||||
/// Turn an eviction-protection flag (favourite/ignored/verified) on/off. Off
|
||||
/// always succeeds; on returns false (no change) once the protected set hits
|
||||
/// the cap (MAX_NUM_NODES-2), keeping >=2 always-evictable slots. Callers
|
||||
/// surface the refusal to the user.
|
||||
bool setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on);
|
||||
|
||||
/*
|
||||
* Returns true if the node is in the NodeDB and marked as favorite
|
||||
@@ -295,6 +312,24 @@ class NodeDB
|
||||
|
||||
virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n);
|
||||
size_t getNumMeshNodes() { return numMeshNodes; }
|
||||
/// Find a node in our DB, create an empty NodeInfoLite if missing (evicting
|
||||
/// the oldest non-protected node when full). Public so admin handlers can
|
||||
/// register a node we have not heard from yet (e.g. to block it by ID).
|
||||
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
// Warm ("long-tail") tier: minimal {num, last_heard, public_key} records
|
||||
// for nodes evicted from the hot store. See WarmNodeStore.h.
|
||||
WarmNodeStore warmStore;
|
||||
#endif
|
||||
|
||||
/// Copy the 32-byte public key for node n — hot store first, then the warm
|
||||
/// tier. Returns false if we don't know a key for n.
|
||||
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// last_heard of a hot-store node, or 0 if absent. Plain scan of meshNodes
|
||||
/// with no allocation side effects (unlike getOrCreateMeshNode).
|
||||
uint32_t hotNodeLastHeard(NodeNum n) const;
|
||||
|
||||
// Thread-safe satellite-map accessors. Return false if absent or the
|
||||
// corresponding DB is compiled out.
|
||||
@@ -341,11 +376,15 @@ class NodeDB
|
||||
emptyNodeDatabase.version = DEVICESTATE_CUR_VER;
|
||||
size_t nodeDatabaseSize;
|
||||
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
|
||||
// Always include satellite slots so backups from higher-cap peers
|
||||
// decode without truncation, even when our build excludes the DBs.
|
||||
return nodeDatabaseSize + (MAX_NUM_NODES * meshtastic_NodeInfoLite_size) +
|
||||
(MAX_NUM_NODES * meshtastic_NodePositionEntry_size) + (MAX_NUM_NODES * meshtastic_NodeTelemetryEntry_size) +
|
||||
(MAX_NUM_NODES * meshtastic_NodeEnvironmentEntry_size) + (MAX_NUM_NODES * meshtastic_NodeStatusEntry_size);
|
||||
// Decode-stream size ceiling only — no buffer this big is allocated (load
|
||||
// streams from the file). Sized for the largest file any prior firmware
|
||||
// could write (250-node ESP32-S3, satellites uncapped) so capacity
|
||||
// downgrades / peer backups still decode; excess is trimmed after load.
|
||||
// (not constexpr: portduino resolves MAX_NUM_NODES from runtime config)
|
||||
const size_t loadCeiling = ((size_t)MAX_NUM_NODES > 250) ? (size_t)MAX_NUM_NODES : 250;
|
||||
return nodeDatabaseSize + (loadCeiling * meshtastic_NodeInfoLite_size) +
|
||||
(loadCeiling * meshtastic_NodePositionEntry_size) + (loadCeiling * meshtastic_NodeTelemetryEntry_size) +
|
||||
(loadCeiling * meshtastic_NodeEnvironmentEntry_size) + (loadCeiling * meshtastic_NodeStatusEntry_size);
|
||||
}
|
||||
|
||||
// returns true if the maximum number of nodes is reached or we are running low on memory
|
||||
@@ -376,6 +415,12 @@ class NodeDB
|
||||
bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest);
|
||||
#endif
|
||||
|
||||
/// Consolidate crypto key generation logic used across multiple modules
|
||||
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
|
||||
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
|
||||
|
||||
bool createNewIdentity();
|
||||
|
||||
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
||||
bool restorePreferences(meshtastic_AdminMessage_BackupLocation location,
|
||||
int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
||||
@@ -388,15 +433,46 @@ class NodeDB
|
||||
newStatus.notifyObservers(&status);
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
/// Re-run loadFromDisk() after the encrypted storage is unlocked at runtime.
|
||||
/// Trigger: PhoneAPI::handleLockdownAuthInline sets lockdownReloadPending
|
||||
/// on a successful provisionPassphrase / unlockWithPassphrase; the main
|
||||
/// loop in main.cpp services the flag and calls this method on the main
|
||||
/// thread. The transport callback stack (BLE/USB) is too small for the
|
||||
/// file IO + MAX_NUM_NODES vector reserve + proto decode this triggers.
|
||||
///
|
||||
/// Returns true iff every encrypted file decrypted and decoded cleanly.
|
||||
/// On false the caller MUST treat the storage as corrupt: leave the
|
||||
/// connection unauthenticated, emit a LOCKED(storage_corrupt) status,
|
||||
/// and refuse to call setAdminAuthorized — otherwise a subsequent
|
||||
/// set_config would re-encrypt a wrong baseline (the locked-default
|
||||
/// values still resident in `config` / `channelFile` / `nodeDatabase`)
|
||||
/// and overwrite the operator's persisted state.
|
||||
bool reloadFromDisk();
|
||||
|
||||
/// Disable lockdown: decrypt every encrypted pref file back to plaintext,
|
||||
/// then remove the DEK / token / counter / backoff artifacts. Requires
|
||||
/// EncryptedStorage to be unlocked (DEK in RAM). Returns false if any
|
||||
/// file failed to revert — in which case the DEK is still present and the
|
||||
/// device remains in lockdown so the operator can retry. APPROTECT is not
|
||||
/// reversed. Called from the main loop via lockdownDisablePending.
|
||||
bool disableLockdownToPlaintext();
|
||||
|
||||
/// Set by loadProto when any encrypted file fails to decrypt or decode.
|
||||
/// Tracked across an entire loadFromDisk pass so reloadFromDisk can
|
||||
/// surface the condition without callers re-walking each loadProto
|
||||
/// result. Cleared at the top of every loadFromDisk run.
|
||||
bool storageCorruptThisLoad = false;
|
||||
#endif
|
||||
|
||||
private:
|
||||
mutable concurrency::Lock satelliteMutex;
|
||||
bool duplicateWarned = false;
|
||||
bool localPositionUpdatedSinceBoot = false;
|
||||
bool migrationSavePending = false;
|
||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
||||
uint32_t lastSort = 0; // When last sorted the nodeDB
|
||||
/// Find a node in our DB, create an empty NodeInfoLite if missing
|
||||
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
|
||||
|
||||
/*
|
||||
* Internal boolean to track sorting paused
|
||||
@@ -409,9 +485,33 @@ class NodeDB
|
||||
/// read our db from flash
|
||||
void loadFromDisk();
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
// Grant the unit-test shim access to the private maintenance paths below
|
||||
// (migration / cleanup / eviction) without relaxing production access.
|
||||
friend class NodeDBTestShim;
|
||||
#endif
|
||||
|
||||
/// purge db entries without user info
|
||||
void cleanupMeshDB();
|
||||
|
||||
/// Trim each satellite map down to MAX_SATELLITE_NODES, dropping the
|
||||
/// stalest entries (used after loading files written before the cap, or by
|
||||
/// a build with a larger cap). Returns true iff anything was trimmed.
|
||||
bool enforceSatelliteCaps();
|
||||
|
||||
/// Node-DB self-care; call only once identity is established (getNodeNum()
|
||||
/// valid). Confirms self is present, trims/demotes only NON-self overflow, and
|
||||
/// rewrites the store once when something changed (never while storage locked).
|
||||
void nodeDBSelfCare();
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
/// A database from a larger-cap build (e.g. the pre-fork 150-node nRF52 store)
|
||||
/// can exceed MAX_NUM_NODES on load. Rank the hot store, demote the oldest
|
||||
/// overflow into the warm tier preserving {num, last_heard, public_key} so PKI
|
||||
/// DMs survive instead of dropping on truncation.
|
||||
void demoteOldestHotNodesToWarm();
|
||||
#endif
|
||||
|
||||
/// Reinit device state from scratch (not loading from disk)
|
||||
void installDefaultDeviceState(), installDefaultNodeDatabase(), installDefaultChannels(),
|
||||
installDefaultConfig(bool preserveKey), installDefaultModuleConfig();
|
||||
@@ -487,7 +587,9 @@ extern uint32_t error_address;
|
||||
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT)
|
||||
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8
|
||||
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT)
|
||||
// Bits 9..31 reserved for future single-bit flags.
|
||||
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9
|
||||
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT)
|
||||
// Bits 10..31 reserved for future single-bit flags.
|
||||
|
||||
// Convenience accessors so call sites read like the old struct fields.
|
||||
inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n)
|
||||
@@ -526,6 +628,16 @@ inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK);
|
||||
}
|
||||
inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
|
||||
}
|
||||
/// A node that the eviction/migration paths must not drop: a favourite, an
|
||||
/// ignored (blocked) node, or a manually-verified key.
|
||||
inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return nodeInfoLiteIsFavorite(n) || nodeInfoLiteIsIgnored(n) || nodeInfoLiteIsKeyManuallyVerified(n);
|
||||
}
|
||||
|
||||
inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "configuration.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
|
||||
#include "meshUtils.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
@@ -88,8 +89,10 @@ bool NodeDB::migrateLegacyNodeDatabase()
|
||||
slim.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||||
strncpy(slim.long_name, legacy.user.long_name, sizeof(slim.long_name));
|
||||
slim.long_name[sizeof(slim.long_name) - 1] = '\0';
|
||||
sanitizeUtf8(slim.long_name, sizeof(slim.long_name)); // replace bad bytes so nanopb encode never fails
|
||||
strncpy(slim.short_name, legacy.user.short_name, sizeof(slim.short_name));
|
||||
slim.short_name[sizeof(slim.short_name) - 1] = '\0';
|
||||
sanitizeUtf8(slim.short_name, sizeof(slim.short_name)); // same — v24 names may contain non-UTF-8 bytes
|
||||
slim.hw_model = legacy.user.hw_model;
|
||||
slim.role = legacy.user.role;
|
||||
if (legacy.user.is_licensed)
|
||||
|
||||
+803
-28
@@ -3,6 +3,12 @@
|
||||
#include "GPS.h"
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
#include "security/LockdownDisplay.h"
|
||||
#endif
|
||||
#include "Channels.h"
|
||||
#include "Default.h"
|
||||
#include "FSCommon.h"
|
||||
@@ -36,6 +42,194 @@
|
||||
// Flag to indicate a heartbeat was received and we should send queue status
|
||||
bool heartbeatReceived = false;
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// Auth-slot table and status-slot table are both sized to the typical
|
||||
// SerialConsole + BluetoothPhoneAPI footprint plus room for WiFi/TCP
|
||||
// transports. Sized together so both tables are keyed identically.
|
||||
static constexpr size_t MAX_AUTH_SLOTS = 6;
|
||||
|
||||
// Per-PhoneAPI pending LockdownStatus. One slot per connection so a
|
||||
// status produced for connection A (e.g. UNLOCKED with the active TTL,
|
||||
// or UNLOCK_FAILED with a backoff) cannot be drained by connection B,
|
||||
// which would otherwise learn that A just authenticated or just failed
|
||||
// — a real information leak across local clients.
|
||||
//
|
||||
// File-scope rather than a per-PhoneAPI member because adding any
|
||||
// non-trivial state directly to PhoneAPI broke USB-CDC enumeration on
|
||||
// the current nRF52 framework; the auth-slot table next door uses the
|
||||
// same workaround. Lifecycle is tied to the auth slot table — both are
|
||||
// keyed by PhoneAPI*, both are cleared together in clearAuthSlot_LH,
|
||||
// and both share g_authSlotsMutex.
|
||||
struct PendingStatusSlot {
|
||||
PhoneAPI *who = nullptr;
|
||||
meshtastic_LockdownStatus status = {};
|
||||
bool hasPending = false;
|
||||
// True between a successful passphrase verify and the main-loop
|
||||
// reloadFromDisk that follows. While set, the connection is NOT
|
||||
// yet authorized and no UNLOCKED status has been emitted — the
|
||||
// client still sees LOCKED, and any admin op it tries is dropped
|
||||
// by the existing unauth gates. Cleared either way by
|
||||
// completePendingUnlocks once reload finishes.
|
||||
bool pendingUnlockAfterReload = false;
|
||||
};
|
||||
static PendingStatusSlot g_statusSlots[MAX_AUTH_SLOTS];
|
||||
|
||||
// Lock-held helpers ---------------------------------------------------------
|
||||
|
||||
static PendingStatusSlot *findOrAllocStatusSlot_LH(PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return nullptr;
|
||||
for (auto &s : g_statusSlots)
|
||||
if (s.who == p)
|
||||
return &s;
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (s.who == nullptr) {
|
||||
s.who = p;
|
||||
s.hasPending = false;
|
||||
s.pendingUnlockAfterReload = false;
|
||||
memset(&s.status, 0, sizeof(s.status));
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
// Mirror the auth-slot eviction policy: stale slots can be reused.
|
||||
// A connection that lost its auth slot has nothing meaningful to be
|
||||
// told via a pending status anyway. Never evict a slot mid-unlock
|
||||
// (pendingUnlockAfterReload set) — completing that flow on the
|
||||
// wrong PhoneAPI would authorize the wrong connection.
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (!s.hasPending && !s.pendingUnlockAfterReload) {
|
||||
s.who = p;
|
||||
memset(&s.status, 0, sizeof(s.status));
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void clearStatusSlot_LH(const PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return;
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (s.who == p) {
|
||||
s.who = nullptr;
|
||||
s.hasPending = false;
|
||||
s.pendingUnlockAfterReload = false;
|
||||
memset(&s.status, 0, sizeof(s.status));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a LockdownStatus message under lock from the supplied fields,
|
||||
// applying the audit's M13 redaction so token_* tamper-detection
|
||||
// strings are not leaked to unauth clients over the wire.
|
||||
static void buildStatus_LH(meshtastic_LockdownStatus &out, meshtastic_LockdownStatus_State state, const char *lock_reason,
|
||||
uint8_t boots_remaining, uint32_t valid_until_epoch, uint32_t backoff_seconds)
|
||||
{
|
||||
memset(&out, 0, sizeof(out));
|
||||
out.state = state;
|
||||
// Collapse the specific token_* reasons to a generic "locked" over
|
||||
// the wire — full detail still goes to local logs. An unauth client
|
||||
// does not need to know whether HMAC failed vs the boot count
|
||||
// hit zero vs the file was the wrong size; all of those mean the
|
||||
// same thing to the client ("locked, ask for passphrase") but
|
||||
// telling them apart over the network lets an attacker confirm
|
||||
// that their tampering or rollback attempt was noticed.
|
||||
const char *wireReason = lock_reason;
|
||||
if (state == meshtastic_LockdownStatus_State_LOCKED && wireReason && wireReason[0] != '\0') {
|
||||
if (strncmp(wireReason, "token_", 6) == 0)
|
||||
wireReason = "locked";
|
||||
}
|
||||
if (wireReason && wireReason[0] != '\0')
|
||||
strncpy(out.lock_reason, wireReason, sizeof(out.lock_reason) - 1);
|
||||
out.boots_remaining = boots_remaining;
|
||||
out.valid_until_epoch = valid_until_epoch;
|
||||
out.backoff_seconds = backoff_seconds;
|
||||
}
|
||||
|
||||
// Per-connection auth state table keyed by PhoneAPI*. Searched linearly;
|
||||
// cost is negligible compared to the redaction gates that call it.
|
||||
struct PhoneAuthSlot {
|
||||
PhoneAPI *who = nullptr;
|
||||
bool authorized = false;
|
||||
uint32_t epoch = 0;
|
||||
};
|
||||
static PhoneAuthSlot g_authSlots[MAX_AUTH_SLOTS];
|
||||
|
||||
// Global auth epoch. Lock Now bumps it; per-slot `epoch` compared against
|
||||
// this. Wraps at 2^32 revocations — practically unreachable; on wrap the
|
||||
// only behavioral effect is that any slot whose epoch happens to match the
|
||||
// new low value would be treated as authorized again, which requires a
|
||||
// pre-existing authorized slot to survive 2^32 lockNow events on the same
|
||||
// boot.
|
||||
static uint32_t g_authEpoch = 1;
|
||||
|
||||
// Single mutex guarding g_authSlots and g_authEpoch. All readers and
|
||||
// writers — including const getters like getAdminAuthorized — must take
|
||||
// it. Granularity is fine because the critical sections are short (a
|
||||
// fixed-size linear scan over 6 entries) and contention is dominated by
|
||||
// getFromRadio's per-call redaction checks, which tolerate brief
|
||||
// blocking.
|
||||
static concurrency::Lock g_authSlotsMutex;
|
||||
|
||||
// Find or allocate the auth slot for `p`. Caller must hold g_authSlotsMutex.
|
||||
// When the table is full of *unauthorized* slots from prior dead PhoneAPIs,
|
||||
// evicts the first unauthorized slot found. Refuses to evict an authorized
|
||||
// slot (those represent a live operator session and must outlive the table
|
||||
// pressure of reconnect churn). Returns nullptr only if every slot is
|
||||
// occupied by a different live, authorized PhoneAPI — practically only
|
||||
// reachable as a DoS via 7+ simultaneous authed connections, in which
|
||||
// case fail-closed and log.
|
||||
static PhoneAuthSlot *findOrAllocSlot_LH(PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return nullptr;
|
||||
for (auto &s : g_authSlots)
|
||||
if (s.who == p)
|
||||
return &s;
|
||||
// First pass: free (who==nullptr) slot.
|
||||
for (auto &s : g_authSlots) {
|
||||
if (s.who == nullptr) {
|
||||
s.who = p;
|
||||
s.authorized = false;
|
||||
s.epoch = 0;
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
// Second pass: evict an unauthorized stale slot. Don't touch authorized
|
||||
// ones — those still represent an operator-authenticated session.
|
||||
for (auto &s : g_authSlots) {
|
||||
if (!s.authorized) {
|
||||
s.who = p;
|
||||
s.epoch = 0;
|
||||
LOG_WARN("Lockdown: auth slot table full, evicted stale unauthorized slot for new PhoneAPI %p", p);
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
LOG_WARN("Lockdown: auth slot table full of authorized sessions, refusing new PhoneAPI %p (fail-closed)", p);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Drop p's slot from both the auth table and the status-queue table.
|
||||
// Lock-held variant.
|
||||
static void clearAuthSlot_LH(const PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return;
|
||||
for (auto &s : g_authSlots) {
|
||||
if (s.who == p) {
|
||||
s.authorized = false;
|
||||
s.epoch = 0;
|
||||
s.who = nullptr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
clearStatusSlot_LH(p);
|
||||
}
|
||||
#endif
|
||||
|
||||
PhoneAPI::PhoneAPI()
|
||||
{
|
||||
lastContactMsec = millis();
|
||||
@@ -45,6 +239,17 @@ PhoneAPI::PhoneAPI()
|
||||
PhoneAPI::~PhoneAPI()
|
||||
{
|
||||
close();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// Free the auth slot unconditionally, regardless of whether close()'s
|
||||
// slot-clear branch ran (it skips when state == STATE_SEND_NOTHING).
|
||||
// Leaving a stale slot.who pointing at freed memory lets a future
|
||||
// PhoneAPI heap-allocated at the same address inherit the prior
|
||||
// session's authorization through findOrAllocSlot.
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
clearAuthSlot_LH(this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void PhoneAPI::handleStartConfig()
|
||||
@@ -55,6 +260,28 @@ void PhoneAPI::handleStartConfig()
|
||||
observe(&service->fromNumChanged);
|
||||
#ifdef FSCom
|
||||
observe(&xModem.packetReady);
|
||||
#endif
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// New physical connection: clear this PhoneAPI's auth slot so the new
|
||||
// client must present a passphrase or PKC admin signature before
|
||||
// seeing full config. Do NOT reset on a subsequent want_config_id
|
||||
// within the same connection: after a successful unlock the client
|
||||
// re-requests config to pull the now-unredacted values, and re-locking
|
||||
// that same-link re-fetch would strip the auth it just earned (config
|
||||
// comes back redacted and set_config writes get dropped).
|
||||
//
|
||||
// The security boundary is therefore the physical connection, not the
|
||||
// want_config handshake. For BLE that boundary is enforced in
|
||||
// onConnect() (which fires once per link and also resets the slot), so
|
||||
// a reconnect re-locks even if this !isConnected() transition was
|
||||
// missed because the prior link's close() raced the new config burst.
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
if (auto *slot = findOrAllocSlot_LH(this)) {
|
||||
slot->authorized = false;
|
||||
slot->epoch = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -157,6 +384,12 @@ void PhoneAPI::close()
|
||||
config_state = 0;
|
||||
pauseBluetoothLogging = false;
|
||||
heartbeatReceived = false;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
clearAuthSlot_LH(this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +418,26 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
|
||||
if (pb_decode_from_bytes(buf, bufLength, &meshtastic_ToRadio_msg, &toRadioScratch)) {
|
||||
switch (toRadioScratch.which_payload_variant) {
|
||||
case meshtastic_ToRadio_packet_tag:
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Allow admin messages addressed to this device — passphrase delivery must get through.
|
||||
// AdminModule handles its own is_managed gate for those.
|
||||
// Block everything else — unauthorized clients cannot inject mesh traffic.
|
||||
// Require the packet to carry a decoded (not encrypted) payload so portnum is valid.
|
||||
// Refuse to match when our own node number is still 0 (NodeDB
|
||||
// not yet loaded — happens during the locked-default boot path
|
||||
// before reloadFromDisk). Otherwise a packet with to==0 would
|
||||
// satisfy the equality and bypass the gate.
|
||||
NodeNum ourNum = nodeDB->getNodeNum();
|
||||
bool isLocalAdmin =
|
||||
ourNum != 0 && toRadioScratch.packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
toRadioScratch.packet.decoded.portnum == meshtastic_PortNum_ADMIN_APP && toRadioScratch.packet.to == ourNum;
|
||||
if (!isLocalAdmin) {
|
||||
LOG_INFO("Lockdown: Dropping non-admin ToRadio packet from unauthorized client");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return handleToRadioPacket(toRadioScratch.packet);
|
||||
case meshtastic_ToRadio_want_config_id_tag:
|
||||
config_nonce = toRadioScratch.want_config_id;
|
||||
@@ -196,6 +449,12 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
|
||||
close();
|
||||
break;
|
||||
case meshtastic_ToRadio_xmodemPacket_tag:
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
LOG_INFO("Lockdown: Dropping xmodem packet from unauthorized client");
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
LOG_INFO("Got xmodem packet");
|
||||
#ifdef FSCom
|
||||
xModem.handlePacket(toRadioScratch.xmodemPacket);
|
||||
@@ -298,6 +557,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env));
|
||||
myNodeInfo.nodedb_count = static_cast<uint16_t>(nodeDB->getNumMeshNodes());
|
||||
fromRadioScratch.my_info = myNodeInfo;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// device_id is a stable hardware identifier — useful for an attacker
|
||||
// to fingerprint / correlate the device across observations. Strip it
|
||||
// for unauthenticated clients. my_node_num is kept (it's broadcast
|
||||
// on the mesh anyway). pio_env / min_app_version reveal the exact
|
||||
// build flavour, useful only for picking which known-CVE to try.
|
||||
// nodedb_count stays — clients need it to decide whether to pull
|
||||
// the node DB after unlocking.
|
||||
fromRadioScratch.my_info.device_id.size = 0;
|
||||
memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes));
|
||||
memset(fromRadioScratch.my_info.pio_env, 0, sizeof(fromRadioScratch.my_info.pio_env));
|
||||
fromRadioScratch.my_info.min_app_version = 0;
|
||||
}
|
||||
#endif
|
||||
state = STATE_SEND_UIDATA;
|
||||
|
||||
service->refreshLocalMeshNode(); // Update my NodeInfo because the client will be asking for it soon.
|
||||
@@ -331,8 +605,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
}
|
||||
if (config_nonce == SPECIAL_NONCE_ONLY_NODES) {
|
||||
// If client only wants node info, jump directly to sending nodes
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
state = STATE_SEND_COMPLETE_ID; // Unauthorized: skip node DB
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
}
|
||||
} else {
|
||||
state = STATE_SEND_METADATA;
|
||||
}
|
||||
@@ -343,12 +624,35 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
LOG_DEBUG("Send device metadata");
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_metadata_tag;
|
||||
fromRadioScratch.metadata = getDeviceMetadata();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// DeviceMetadata is one large fingerprint vector for an unauth
|
||||
// client: firmware_version, device_state_version, hw_model,
|
||||
// hw_model_string, has_bluetooth/has_wifi/has_ethernet, role,
|
||||
// position_flags, excluded_modules, optionsCount. None of it
|
||||
// is needed to drive lockdown_auth, and most of it tells an
|
||||
// attacker which CVE / behavior quirks to probe. Wipe the
|
||||
// whole struct — clients re-fetch once authenticated.
|
||||
memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata));
|
||||
}
|
||||
#endif
|
||||
state = STATE_SEND_CHANNELS;
|
||||
break;
|
||||
|
||||
case STATE_SEND_CHANNELS:
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_channel_tag;
|
||||
fromRadioScratch.channel = channels.getByIndex(config_state);
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit a zero-initialized Channel. fromRadioScratch
|
||||
// was memset(0) at the top of getFromRadio(), so leaving .channel
|
||||
// untouched gives the client an empty entry — no name, no PSK, no
|
||||
// role. Advances the state machine normally so config_complete_id
|
||||
// still fires.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.channel = channels.getByIndex(config_state);
|
||||
}
|
||||
config_state++;
|
||||
// Advance when we have sent all of our Channels
|
||||
if (config_state >= MAX_NUM_CHANNELS) {
|
||||
@@ -380,7 +684,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_Config_network_tag:
|
||||
LOG_DEBUG("Send config: network");
|
||||
fromRadioScratch.config.which_payload_variant = meshtastic_Config_network_tag;
|
||||
fromRadioScratch.config.payload_variant.network = config.network;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit an empty NetworkConfig (zero-init from the
|
||||
// top-of-loop memset). No wifi_psk, no SSID, no static IP info.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.config.payload_variant.network = config.network;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_display_tag:
|
||||
LOG_DEBUG("Send config: display");
|
||||
@@ -390,7 +702,28 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_Config_lora_tag:
|
||||
LOG_DEBUG("Send config: lora");
|
||||
fromRadioScratch.config.which_payload_variant = meshtastic_Config_lora_tag;
|
||||
fromRadioScratch.config.payload_variant.lora = config.lora;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Whitelist only the spec-mandated radio identity fields that
|
||||
// are intrinsically observable on the air anyway: region,
|
||||
// modem_preset, use_preset, channel_num, hop_limit. Operator-
|
||||
// private knobs (ignore_incoming list, override_duty_cycle,
|
||||
// override_frequency, sx126x_rx_boosted_gain, tx_power,
|
||||
// ignore_mqtt, fem_lna_mode, config_ok_to_mqtt, ...) stay
|
||||
// hidden — they tell an attacker how the operator has tuned
|
||||
// the device but are not needed by an unauth client.
|
||||
meshtastic_Config_LoRaConfig whitelist = {};
|
||||
whitelist.use_preset = config.lora.use_preset;
|
||||
whitelist.modem_preset = config.lora.modem_preset;
|
||||
whitelist.region = config.lora.region;
|
||||
whitelist.channel_num = config.lora.channel_num;
|
||||
whitelist.hop_limit = config.lora.hop_limit;
|
||||
fromRadioScratch.config.payload_variant.lora = whitelist;
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.config.payload_variant.lora = config.lora;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_bluetooth_tag:
|
||||
LOG_DEBUG("Send config: bluetooth");
|
||||
@@ -400,7 +733,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_Config_security_tag:
|
||||
LOG_DEBUG("Send config: security");
|
||||
fromRadioScratch.config.which_payload_variant = meshtastic_Config_security_tag;
|
||||
fromRadioScratch.config.payload_variant.security = config.security;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit an empty SecurityConfig (zero-init from
|
||||
// the top-of-loop memset). No private_key, no admin_keys, no
|
||||
// public_key — nothing for an attacker to inspect.
|
||||
//
|
||||
// Provisioning state (NEEDS_PROVISION vs LOCKED) is conveyed via
|
||||
// the FromRadio.lockdown_status proto sent post-config; clients
|
||||
// should consume that rather than inferring from this empty
|
||||
// security config.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.config.payload_variant.security = config.security;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_sessionkey_tag:
|
||||
LOG_DEBUG("Send config: sessionkey");
|
||||
@@ -430,7 +777,17 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_ModuleConfig_mqtt_tag:
|
||||
LOG_DEBUG("Send module config: mqtt");
|
||||
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
|
||||
fromRadioScratch.moduleConfig.payload_variant.mqtt = moduleConfig.mqtt;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit an empty MQTTConfig (zero-init from
|
||||
// the top-of-loop memset). MQTT broker username/password, the
|
||||
// server address, and root_topic are credentials/config that
|
||||
// shouldn't be visible to an unauth client.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.moduleConfig.payload_variant.mqtt = moduleConfig.mqtt;
|
||||
}
|
||||
break;
|
||||
case meshtastic_ModuleConfig_serial_tag:
|
||||
LOG_DEBUG("Send module config: serial");
|
||||
@@ -509,15 +866,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
config_state++;
|
||||
// Advance when we have sent all of our ModuleConfig objects
|
||||
if (config_state > (_meshtastic_AdminMessage_ModuleConfigType_MAX + 1)) {
|
||||
// Handle special nonce behaviors:
|
||||
// - SPECIAL_NONCE_ONLY_CONFIG: Skip node info, go directly to file manifest
|
||||
// - SPECIAL_NONCE_ONLY_NODES: After sending nodes, skip to complete
|
||||
if (config_nonce == SPECIAL_NONCE_ONLY_CONFIG) {
|
||||
state = STATE_SEND_FILEMANIFEST;
|
||||
} else {
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
}
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthorized client: skip node DB and file manifest — only send config complete
|
||||
state = STATE_SEND_COMPLETE_ID;
|
||||
} else
|
||||
#endif
|
||||
// Handle special nonce behaviors:
|
||||
// - SPECIAL_NONCE_ONLY_CONFIG: Skip node info, go directly to file manifest
|
||||
// - SPECIAL_NONCE_ONLY_NODES: After sending nodes, skip to complete
|
||||
if (config_nonce == SPECIAL_NONCE_ONLY_CONFIG) {
|
||||
state = STATE_SEND_FILEMANIFEST;
|
||||
} else {
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
}
|
||||
config_state = 0;
|
||||
}
|
||||
break;
|
||||
@@ -590,24 +953,58 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
fromRadioScratch.queueStatus = *queueStatusPacketForPhone;
|
||||
releaseQueueStatusPhonePacket();
|
||||
} else if (mqttClientProxyMessageForPhone) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
|
||||
fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
|
||||
releaseMqttClientProxyPhonePacket();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
releaseMqttClientProxyPhonePacket(); // Discard — unauthorized client
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
|
||||
fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
|
||||
releaseMqttClientProxyPhonePacket();
|
||||
}
|
||||
} else if (xmodemPacketForPhone.control != meshtastic_XModem_Control_NUL) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
|
||||
fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
|
||||
xmodemPacketForPhone = meshtastic_XModem_init_zero;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
xmodemPacketForPhone = meshtastic_XModem_init_zero; // Discard — unauthorized client
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
|
||||
fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
|
||||
xmodemPacketForPhone = meshtastic_XModem_init_zero;
|
||||
}
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
} else if (hasPendingLockdownStatus()) {
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
// Look up our own slot only — never another connection's. Re-check
|
||||
// hasPending under the lock since a concurrent drain on the same
|
||||
// connection (unlikely but possible if multiple transport
|
||||
// callbacks race against one PhoneAPI) may have grabbed it.
|
||||
if (auto *slot = findOrAllocStatusSlot_LH(this); slot && slot->hasPending) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_lockdown_status_tag;
|
||||
fromRadioScratch.lockdown_status = slot->status;
|
||||
memset(&slot->status, 0, sizeof(slot->status));
|
||||
slot->hasPending = false;
|
||||
}
|
||||
#endif
|
||||
} else if (clientNotification) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_clientNotification_tag;
|
||||
fromRadioScratch.clientNotification = *clientNotification;
|
||||
releaseClientNotification();
|
||||
} else if (packetForPhone) {
|
||||
printPacket("phone downloaded packet", packetForPhone);
|
||||
|
||||
// Encapsulate as a FromRadio packet
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||
fromRadioScratch.packet = *packetForPhone;
|
||||
releasePhonePacket();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
releasePhonePacket(); // Discard mesh traffic — unauthorized client
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
printPacket("phone downloaded packet", packetForPhone);
|
||||
// Encapsulate as a FromRadio packet
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||
fromRadioScratch.packet = *packetForPhone;
|
||||
releasePhonePacket();
|
||||
}
|
||||
} else if (replayPending()) {
|
||||
// No live packet pending — feed the phone one cached satellite-DB packet.
|
||||
// popReplayPacket advances through positions->telemetry->environment->status,
|
||||
@@ -669,6 +1066,29 @@ void PhoneAPI::sendConfigComplete()
|
||||
service->api_state = service->STATE_ETH;
|
||||
}
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
if (!EncryptedStorage::isLockdownActive()) {
|
||||
// Lockdown-capable firmware, but lockdown is not active on this
|
||||
// device (never provisioned, or disabled). Tell the client so its
|
||||
// "lockdown mode" toggle renders OFF. Note getAdminAuthorized()
|
||||
// returns true in this state, so the redaction gates are no-ops and
|
||||
// the client just received the full, unredacted config above.
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: DISABLED (lockdown not active) sent to client");
|
||||
} else if (!getAdminAuthorized()) {
|
||||
if (!EncryptedStorage::isProvisioned()) {
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_NEEDS_PROVISION, "", 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: NEEDS_PROVISION sent to client");
|
||||
} else if (!EncryptedStorage::isUnlocked()) {
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, EncryptedStorage::getLockReason(), 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: LOCKED (%s) sent to client", EncryptedStorage::getLockReason());
|
||||
} else {
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "needs_auth", 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: LOCKED (needs_auth) sent to client");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Allow subclasses to know we've entered steady-state so they can lower power consumption
|
||||
onConfigComplete();
|
||||
|
||||
@@ -1121,6 +1541,10 @@ bool PhoneAPI::available()
|
||||
if (!clientNotification)
|
||||
clientNotification = service->getClientNotificationForPhone();
|
||||
bool hasPacket = !!queueStatusPacketForPhone || !!mqttClientProxyMessageForPhone || !!clientNotification;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (hasPendingLockdownStatus())
|
||||
hasPacket = true;
|
||||
#endif
|
||||
if (hasPacket)
|
||||
return true;
|
||||
|
||||
@@ -1192,6 +1616,46 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
|
||||
{
|
||||
printPacket("PACKET FROM PHONE", &p);
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
// Local admin gating happens here, synchronously on the dispatching
|
||||
// task. Two distinct cases:
|
||||
//
|
||||
// (a) lockdown_auth: handled inline. Passphrase never enters the
|
||||
// routed MeshPacket queue, and authorize-this-connection
|
||||
// runs while `this` is still on the call stack.
|
||||
//
|
||||
// (b) Any other admin payload from an unauthorized connection:
|
||||
// dropped here. The previous design relied on AdminModule
|
||||
// to apply isLocalAdminAuthorized() during dispatch, but
|
||||
// AdminModule runs on the Router task — by then the
|
||||
// PhoneAPI dispatching task has already exited and the
|
||||
// per-connection auth context is unrecoverable. Putting
|
||||
// the gate here closes that race and covers H6/H7 from the
|
||||
// audit: get_config_request and set_config from unauthed
|
||||
// clients no longer reach AdminModule at all.
|
||||
if (p.from == 0 && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) {
|
||||
meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
|
||||
if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) {
|
||||
if (admin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) {
|
||||
handleLockdownAuthInline(admin.lockdown_auth);
|
||||
// Wipe the decoded passphrase scratch — the byte array in
|
||||
// p.decoded.payload.bytes is wiped by handleLockdownAuthInline.
|
||||
volatile uint8_t *adminVol = const_cast<volatile uint8_t *>(admin.lockdown_auth.passphrase.bytes);
|
||||
for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++)
|
||||
adminVol[i] = 0;
|
||||
return true;
|
||||
}
|
||||
if (!getAdminAuthorized()) {
|
||||
LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// pb_decode failure: fall through to normal handling so the
|
||||
// regular Router/AdminModule reject path can respond.
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
// For use with the simulator, we should not ignore duplicate packets from the phone
|
||||
if (SimRadio::instance == nullptr)
|
||||
@@ -1260,3 +1724,314 @@ int PhoneAPI::onNotify(uint32_t newValue)
|
||||
|
||||
return timeout ? -1 : 0; // If we timed out, MeshService should stop iterating through observers as we just removed one
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
bool PhoneAPI::getAdminAuthorized() const
|
||||
{
|
||||
// Runtime-toggle model: when lockdown is NOT active (a lockdown-capable
|
||||
// build that hasn't been provisioned, or that was disabled), there is
|
||||
// nothing to protect — every connection is implicitly authorized, so
|
||||
// all the `if (!getAdminAuthorized())` redaction gates throughout
|
||||
// getFromRadio() / handleToRadio() become no-ops and the device serves
|
||||
// config exactly like stock firmware. Only once provisioned (lockdown
|
||||
// active) do we consult the per-connection auth slot table.
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
if (!EncryptedStorage::isLockdownActive())
|
||||
return true;
|
||||
#endif
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
// const_cast is safe — findOrAllocSlot_LH only mutates the slot table,
|
||||
// not the PhoneAPI itself, and the table key is just the pointer.
|
||||
const auto *slot = findOrAllocSlot_LH(const_cast<PhoneAPI *>(this));
|
||||
return slot && slot->authorized && slot->epoch == g_authEpoch;
|
||||
}
|
||||
|
||||
void PhoneAPI::setAdminAuthorized(bool authorized)
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
auto *slot = findOrAllocSlot_LH(this);
|
||||
if (!slot)
|
||||
return; // slot table full — fail-closed
|
||||
if (authorized) {
|
||||
slot->epoch = g_authEpoch;
|
||||
slot->authorized = true;
|
||||
} else {
|
||||
slot->authorized = false;
|
||||
slot->epoch = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void PhoneAPI::revokeAllAuth()
|
||||
{
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
g_authEpoch++;
|
||||
}
|
||||
LOG_INFO("Lockdown: All connection auth revoked (Lock Now)");
|
||||
}
|
||||
|
||||
void PhoneAPI::completePendingUnlocks(bool reloadOk)
|
||||
{
|
||||
// Snapshot fields that we'll need outside the lock (we cannot call
|
||||
// EncryptedStorage / setAdminAuthorized / unlockScreen while holding
|
||||
// g_authSlotsMutex without risking re-entry — setAdminAuthorized
|
||||
// itself takes the same lock).
|
||||
constexpr size_t kMaxSnapshots = MAX_AUTH_SLOTS;
|
||||
PhoneAPI *targets[kMaxSnapshots] = {};
|
||||
size_t targetCount = 0;
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (!s.pendingUnlockAfterReload || !s.who)
|
||||
continue;
|
||||
if (targetCount < kMaxSnapshots)
|
||||
targets[targetCount++] = s.who;
|
||||
// Clear the pending flag either way — failure path must not
|
||||
// leave it set so a subsequent successful reload retries
|
||||
// against the wrong PhoneAPI.
|
||||
s.pendingUnlockAfterReload = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (reloadOk) {
|
||||
uint8_t boots = EncryptedStorage::getBootsRemaining();
|
||||
uint32_t until = EncryptedStorage::getValidUntilEpoch();
|
||||
for (size_t i = 0; i < targetCount; i++) {
|
||||
PhoneAPI *p = targets[i];
|
||||
p->setAdminAuthorized(true);
|
||||
p->queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", boots, until, 0);
|
||||
}
|
||||
// Screen-lock latch is cleared once any client successfully
|
||||
// unlocks — the operator has proven the passphrase. Matches the
|
||||
// re-verify path's behavior.
|
||||
if (targetCount > 0)
|
||||
meshtastic_security::unlockScreen();
|
||||
LOG_INFO("Lockdown: post-reload completion: authorized %u connection(s)", (unsigned)targetCount);
|
||||
} else {
|
||||
// Storage corrupt — emit LOCKED(storage_corrupt) to every slot
|
||||
// that was awaiting the unlock. setAdminAuthorized is NOT called
|
||||
// so the connection stays redacted and any set_config it sends
|
||||
// is dropped at the existing unauth gates. Caller (main.cpp) has
|
||||
// already lockNow'd storage and broadcast-revoked.
|
||||
for (size_t i = 0; i < targetCount; i++) {
|
||||
targets[i]->queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "storage_corrupt", 0, 0, 0);
|
||||
}
|
||||
LOG_ERROR("Lockdown: post-reload completion: storage corrupt, notified %u connection(s)", (unsigned)targetCount);
|
||||
}
|
||||
}
|
||||
|
||||
void PhoneAPI::queueLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds)
|
||||
{
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
auto *slot = findOrAllocStatusSlot_LH(this);
|
||||
if (!slot)
|
||||
return; // slot table exhausted — fail-closed, no status delivered
|
||||
buildStatus_LH(slot->status, state, lock_reason, boots_remaining, valid_until_epoch, backoff_seconds);
|
||||
slot->hasPending = true;
|
||||
}
|
||||
if (service)
|
||||
service->nudgeFromNum();
|
||||
}
|
||||
|
||||
void PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds)
|
||||
{
|
||||
bool anyOverwritten = false;
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (s.who) {
|
||||
buildStatus_LH(s.status, state, lock_reason, boots_remaining, valid_until_epoch, backoff_seconds);
|
||||
s.hasPending = true;
|
||||
anyOverwritten = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Service nudge is shared across connections; one nudge wakes every
|
||||
// drainer. Skip if no connection currently has a slot.
|
||||
if (anyOverwritten && service)
|
||||
service->nudgeFromNum();
|
||||
}
|
||||
|
||||
bool PhoneAPI::hasPendingLockdownStatus() const
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
for (const auto &s : g_statusSlots) {
|
||||
if (s.who == this && s.hasPending)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
|
||||
{
|
||||
// Wipe passphrase bytes in the caller's decoded scratch on every exit.
|
||||
auto zeroPassphrase = [&]() {
|
||||
volatile uint8_t *ppVol = const_cast<volatile uint8_t *>(la.passphrase.bytes);
|
||||
for (pb_size_t zi = 0; zi < la.passphrase.size; zi++)
|
||||
ppVol[zi] = 0;
|
||||
};
|
||||
|
||||
// Lock Now — only honored from a connection that has already proven
|
||||
// the passphrase. Unauthenticated clients used to be able to trigger
|
||||
// a reboot, which was a trivial local-presence DoS (any BLE/USB
|
||||
// attacker could brick-loop the device). Now lock_now requires
|
||||
// prior auth on this connection.
|
||||
if (la.lock_now) {
|
||||
if (!getAdminAuthorized()) {
|
||||
LOG_WARN("Lockdown: LOCK NOW from unauthorized connection — denied");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
LOG_INFO("Lockdown: LOCK NOW command received from authorized connection");
|
||||
EncryptedStorage::lockNow();
|
||||
revokeAllAuth();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Disable lockdown entirely. Requires the passphrase (must prove
|
||||
// ownership before reverting at-rest encryption). We verify it here to
|
||||
// load the DEK, then hand the heavy decrypt-revert work to the main
|
||||
// loop via lockdownDisablePending — exactly like the unlock reload
|
||||
// path, because decrypting + rewriting nodes.proto is too heavy for
|
||||
// this transport-callback stack. APPROTECT is NOT reversed.
|
||||
if (la.disable) {
|
||||
if (la.passphrase.size < 1) {
|
||||
LOG_WARN("Lockdown: disable with empty passphrase — rejecting");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
if (!EncryptedStorage::isLockdownActive()) {
|
||||
// Already off — nothing to do; report DISABLED so the client UI settles.
|
||||
LOG_INFO("Lockdown: disable requested but lockdown is not active");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
// Re-verify the passphrase (loads the DEK needed to decrypt files).
|
||||
bool ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size,
|
||||
EncryptedStorage::TOKEN_DEFAULT_BOOTS, 0, 0);
|
||||
if (!ok) {
|
||||
uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, backoff);
|
||||
LOG_WARN("Lockdown: disable passphrase verification failed");
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
setAdminAuthorized(true);
|
||||
lockdownDisablePending = true; // main loop runs nodeDB->disableLockdownToPlaintext() then reboots
|
||||
LOG_INFO("Lockdown: disable authorized, deferring decrypt-revert to main loop");
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Empty-passphrase auth was previously a silent success — clients
|
||||
// got no feedback and the device looked the same as it would after
|
||||
// an actual no-op. Emit UNLOCK_FAILED with no backoff so honest
|
||||
// clients can detect their own bug and an attacker still learns
|
||||
// nothing they wouldn't from any other failed attempt.
|
||||
if (la.passphrase.size < 1) {
|
||||
LOG_WARN("Lockdown: lockdown_auth with empty passphrase and lock_now=false — rejecting");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
|
||||
// boots_remaining is uint32 on the wire but the token field is uint8.
|
||||
// Silently truncating (256 -> 0 -> default 50) hides a real client
|
||||
// bug. Reject explicitly so the client can correct its request.
|
||||
if (la.boots_remaining > 255) {
|
||||
LOG_WARN("Lockdown: boots_remaining=%u exceeds uint8 cap, rejecting", la.boots_remaining);
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t boots = la.boots_remaining != 0 ? (uint8_t)la.boots_remaining : EncryptedStorage::TOKEN_DEFAULT_BOOTS;
|
||||
uint32_t validUntilEpoch = la.valid_until_epoch;
|
||||
// Client-supplied session cap when present; otherwise the
|
||||
// firmware-side default. 0 from the client means "use firmware
|
||||
// default", consistent with the boots_remaining sentinel.
|
||||
uint32_t sessionMaxSeconds =
|
||||
la.max_session_seconds != 0 ? la.max_session_seconds : MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS;
|
||||
|
||||
bool ok = false;
|
||||
bool needsReload = false;
|
||||
if (!EncryptedStorage::isUnlocked()) {
|
||||
if (!EncryptedStorage::isProvisioned()) {
|
||||
LOG_INFO("Lockdown: first-time provisioning with passphrase");
|
||||
ok = EncryptedStorage::provisionPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
|
||||
sessionMaxSeconds);
|
||||
} else {
|
||||
LOG_INFO("Lockdown: unlock with passphrase");
|
||||
ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
|
||||
sessionMaxSeconds);
|
||||
}
|
||||
if (ok) {
|
||||
needsReload = true;
|
||||
// Mark this slot for the main-loop completion handler. Don't
|
||||
// authorize or emit UNLOCKED yet — `config` / `channelFile`
|
||||
// / `nodeDatabase` still hold the locked-default placeholders
|
||||
// installed by loadFromDisk()'s !isUnlocked() branch. If we
|
||||
// flipped the connection to authorized here, the client could
|
||||
// read those placeholders as if they were the operator's real
|
||||
// settings, or set_config write a corrupted baseline that
|
||||
// overwrites the real config when reloadFromDisk swaps them
|
||||
// in. completePendingUnlocks() runs on the main thread after
|
||||
// reloadFromDisk has populated the real values and the radio
|
||||
// has been reconfigured.
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
if (auto *slot = findOrAllocStatusSlot_LH(this))
|
||||
slot->pendingUnlockAfterReload = true;
|
||||
}
|
||||
lockdownReloadPending = true;
|
||||
LOG_INFO("Lockdown: storage unlocked, awaiting reload before client visibility");
|
||||
}
|
||||
} else {
|
||||
LOG_INFO("Lockdown: passphrase re-verify for admin authorization");
|
||||
ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
|
||||
sessionMaxSeconds);
|
||||
if (ok) {
|
||||
// Storage was already unlocked — no reload needed. Authorize
|
||||
// and surface UNLOCKED to the client immediately.
|
||||
setAdminAuthorized(true);
|
||||
LOG_INFO("Lockdown: passphrase verified, this connection authorized");
|
||||
}
|
||||
}
|
||||
|
||||
if (ok && !needsReload) {
|
||||
// Re-verify path: storage was already unlocked. Clear the screen
|
||||
// latch and emit UNLOCKED now. The cold-unlock path defers both
|
||||
// of these to completePendingUnlocks() once reloadFromDisk finishes.
|
||||
meshtastic_security::unlockScreen();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", EncryptedStorage::getBootsRemaining(),
|
||||
EncryptedStorage::getValidUntilEpoch(), 0);
|
||||
} else if (ok && needsReload) {
|
||||
// Cold-unlock path: deliberately no status emission yet — the
|
||||
// client keeps seeing LOCKED until completePendingUnlocks()
|
||||
// runs after a successful reload.
|
||||
} else {
|
||||
uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, backoff);
|
||||
// Don't log backoff seconds — the client receives it in the
|
||||
// UNLOCK_FAILED status anyway, and in non-DEBUG_MUTE builds the
|
||||
// numeric value would otherwise spill onto a USB-attached
|
||||
// attacker's serial terminal alongside other diagnostic noise.
|
||||
LOG_WARN("Lockdown: passphrase verification failed");
|
||||
(void)backoff;
|
||||
}
|
||||
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
#endif // MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#endif // MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "concurrency/Lock.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "meshtastic/portnums.pb.h"
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <iterator>
|
||||
@@ -170,6 +171,49 @@ class PhoneAPI
|
||||
bool isConnected() { return state != STATE_SEND_NOTHING; }
|
||||
bool isSendingPackets() { return state == STATE_SEND_PACKETS; }
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
/// Per-connection auth: tracked in a small file-scope slot table keyed
|
||||
/// by PhoneAPI*. Adding state members directly to PhoneAPI broke
|
||||
/// USB-CDC enumeration on current nRF52 framework — even one extra
|
||||
/// per-instance uint32_t was enough. Keeping all state out-of-line
|
||||
/// avoids the issue.
|
||||
void setAdminAuthorized(bool authorized);
|
||||
bool getAdminAuthorized() const;
|
||||
|
||||
/// Lock Now: O(1) invalidation of every connection's auth by advancing
|
||||
/// the global epoch. Subsequent gate checks see slot.myEpoch != epoch
|
||||
/// and treat the connection as unauthenticated.
|
||||
static void revokeAllAuth();
|
||||
|
||||
/// Called from the main loop after NodeDB::reloadFromDisk() finishes.
|
||||
/// On reloadOk=true: any connection marked pending-unlock-after-reload
|
||||
/// is promoted to authorized and receives an UNLOCKED status; the
|
||||
/// screen-lock latch clears. On reloadOk=false: those connections
|
||||
/// receive a LOCKED(storage_corrupt) status and remain unauthorized
|
||||
/// so they cannot drive set_config against the corrupt baseline.
|
||||
static void completePendingUnlocks(bool reloadOk);
|
||||
|
||||
/// Queue a LockdownStatus FromRadio for THIS connection only. Each
|
||||
/// PhoneAPI owns its own pending-status slot in a file-scope table
|
||||
/// (file-scope because adding fields directly to PhoneAPI broke
|
||||
/// USB-CDC enumeration on nRF52); a status produced here will not
|
||||
/// be delivered to any other connection. `lock_reason` may be
|
||||
/// nullptr / empty for non-LOCKED states.
|
||||
void queueLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds);
|
||||
|
||||
/// Queue the same LockdownStatus on every active connection's slot.
|
||||
/// Use for events with no specific originating connection (session
|
||||
/// expiry tick in main.cpp, broadcast revocations, etc.). Per-
|
||||
/// connection callers should prefer the instance method above to
|
||||
/// avoid leaking one client's auth state to another.
|
||||
static void broadcastLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds);
|
||||
|
||||
/// True iff this connection has a pending lockdown_status drain.
|
||||
bool hasPendingLockdownStatus() const;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// Our fromradio packet while it is being assembled
|
||||
meshtastic_FromRadio fromRadioScratch = {};
|
||||
@@ -211,6 +255,20 @@ class PhoneAPI
|
||||
|
||||
APIType api_type = TYPE_NONE;
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// No per-instance auth members — see method-level note. All state lives
|
||||
// in a file-scope slot table in PhoneAPI.cpp keyed by `this` pointer.
|
||||
|
||||
// Pending LockdownStatus storage is NOT a class member — having a
|
||||
// meshtastic_LockdownStatus (~50 bytes with the char[33] lock_reason)
|
||||
// as a PhoneAPI member broke USB-CDC enumeration on the nRF52 Adafruit
|
||||
// framework. The exact mechanism wasn't pinned down, but moving the
|
||||
// storage to a file-scope static in PhoneAPI.cpp side-steps it cleanly.
|
||||
// Trade-off: all PhoneAPI instances share one pending slot. Acceptable
|
||||
// because only one transport delivers a lockdown command at a time in
|
||||
// any realistic scenario.
|
||||
#endif
|
||||
|
||||
private:
|
||||
void releasePhonePacket();
|
||||
|
||||
@@ -248,6 +306,16 @@ class PhoneAPI
|
||||
*/
|
||||
bool handleToRadioPacket(meshtastic_MeshPacket &p);
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
/// Synchronously handle a lockdown_auth AdminMessage from the local
|
||||
/// client. Runs inside handleToRadioPacket so the originating
|
||||
/// connection is reachable via `this` — avoids the async context
|
||||
/// loss that broke the previous AdminModule path. Always consumes the
|
||||
/// packet (returns true): lockdown_auth is local-only and must not be
|
||||
/// forwarded to the mesh router.
|
||||
bool handleLockdownAuthInline(const meshtastic_LockdownAuth &la);
|
||||
#endif
|
||||
|
||||
/// If the mesh service tells us fromNum has changed, tell the phone
|
||||
virtual int onNotify(uint32_t newValue) override;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,16 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel)
|
||||
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
|
||||
{
|
||||
return getPositionPrecisionForChannel(channels.getByIndex(channelIndex));
|
||||
const meshtastic_Channel &ch = channels.getByIndex(channelIndex);
|
||||
if (ch.role == meshtastic_Channel_Role_DISABLED)
|
||||
return 0;
|
||||
uint32_t precision = getPositionPrecisionForChannel(ch);
|
||||
|
||||
// Never send a precise position on a publicly-decryptable channel (key check is gated on > ceiling).
|
||||
if (precision > MAX_POSITION_PRECISION_PUBLIC_KEY && channels.usesPublicKey(channelIndex)) {
|
||||
precision = MAX_POSITION_PRECISION_PUBLIC_KEY;
|
||||
}
|
||||
return precision;
|
||||
}
|
||||
|
||||
static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
#include "meshtastic/mesh.pb.h"
|
||||
#include <stdint.h>
|
||||
|
||||
// Max precision on a publicly-decryptable channel. CCPA "precise geolocation" = within a ~564m (1,850ft) radius.
|
||||
// Precision is bit-truncation of latitude_i/longitude_i: the latitude cell stays ~constant in meters worldwide
|
||||
// (~700m at 15 bits), while only the longitude cell varies — widest at the equator, narrowing toward the poles.
|
||||
// 15 also matches the MQTT map-report public precision ceiling.
|
||||
#define MAX_POSITION_PRECISION_PUBLIC_KEY 15
|
||||
|
||||
// Configured precision as-is; does NOT apply the public-key clamp -- use the channelIndex overload for the on-wire value.
|
||||
uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
|
||||
|
||||
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
|
||||
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision);
|
||||
bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision);
|
||||
|
||||
+175
-41
@@ -49,6 +49,8 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_LITE[] = {PRESET(L
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET(NARROW_FAST), PRESET(NARROW_SLOW),
|
||||
MODEM_PRESET_END};
|
||||
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END};
|
||||
|
||||
// Region profiles: bundle preset list + regulatory parameters shared across regions
|
||||
// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle
|
||||
const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1};
|
||||
@@ -56,6 +58,12 @@ const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 1, 1
|
||||
const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 1, 1};
|
||||
const RegionProfile PROFILE_LITE = {PRESETS_LITE, 0.4, 0.0375f, false, false, 0, 10, 10};
|
||||
const RegionProfile PROFILE_NARROW = {PRESETS_NARROW, 0, 0.0104f, true, false, 0, 1, 1};
|
||||
// Ham '20kHz' profile. 15.6kHz bandwidth coerced to 20kHz via padding.
|
||||
const RegionProfile PROFILE_HAM_20KHZ = {PRESETS_TINY, 0, 0.0022f, false, true, 0, 2, 2};
|
||||
// Ham '100kHz' profile. 62.5kHz bandwidth coerced to 100kHz via padding.
|
||||
const RegionProfile PROFILE_HAM_100KHZ = {PRESETS_NARROW, 0, 0.01875f, false, true, 0, 1, 1};
|
||||
|
||||
Observable<uint32_t> RadioInterface::loraRxPacketObservable;
|
||||
|
||||
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset, \
|
||||
override_slot) \
|
||||
@@ -79,20 +87,30 @@ const RegionInfo regions[] = {
|
||||
*/
|
||||
RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
/*
|
||||
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
https://www.legislation.gov.uk/uksi/1999/930/schedule/6/part/III/made/data.xht?view=snippet&wrap=true
|
||||
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
https://www.legislation.gov.uk/uksi/1999/930/schedule/6/part/III/made/data.xht?view=snippet&wrap=true
|
||||
|
||||
audio_permitted = false per regulation
|
||||
audio_permitted = false per regulation
|
||||
|
||||
Special Note:
|
||||
The link above describes LoRaWAN's band plan, stating a power limit of 16 dBm. This is their own suggested specification,
|
||||
we do not need to follow it. The European Union regulations clearly state that the power limit for this frequency range is
|
||||
500 mW, or 27 dBm. It also states that we can use interference avoidance and spectrum access techniques (such as LBT +
|
||||
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
|
||||
*/
|
||||
Special Note:
|
||||
The link above describes LoRaWAN's band plan, stating a power limit of 16 dBm. This is their own suggested specification,
|
||||
we do not need to follow it. The European Union regulations clearly state that the power limit for this frequency range is
|
||||
500 mW, or 27 dBm. It also states that we can use interference avoidance and spectrum access techniques (such as LBT +
|
||||
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
|
||||
|
||||
EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD)
|
||||
Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm,
|
||||
duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123
|
||||
|
||||
EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz
|
||||
Channel centres at 869.442/869.525/869.608 MHz,
|
||||
10.4 kHz padding on channels, 27 dBm, duty cycle 10%
|
||||
*/
|
||||
RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST), 0),
|
||||
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST), 0),
|
||||
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW), 1),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
@@ -226,25 +244,50 @@ const RegionInfo regions[] = {
|
||||
*/
|
||||
RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
ITU Region 1 (Europe, Africa, Middle East, former USSR) amateur 2m allocation: 144.000 - 146.000 MHz.
|
||||
Power limit is the regulatory ceiling (1 W / 30 dBm) — individual hardware will cap below this
|
||||
via its own PA curve; the field here is just the legal upper bound.
|
||||
|
||||
Default slot: 26 (144.510 MHz)
|
||||
https://www.iaru-r1.org/wp-content/uploads/2020/12/VHF-Bandplan.pdf
|
||||
*/
|
||||
RDEF(ITU1_2M, 144.0f, 146.0f, 100, 30, false, false, PROFILE_HAM_20KHZ, PRESET(TINY_FAST), 26),
|
||||
|
||||
/*
|
||||
ITU Region 2 (Americas) amateur 2m allocation: 144.000 - 148.000 MHz.
|
||||
Typical admin rules (e.g. US FCC Part 97) allow well above 30 dBm for licensed operators.
|
||||
|
||||
Default slot: 51 (145.010 MHz)
|
||||
https://www.arrl.org/band-plan
|
||||
*/
|
||||
RDEF(ITU2_2M, 144.0f, 148.0f, 100, 30, false, false, PROFILE_HAM_20KHZ, PRESET(TINY_FAST), 51),
|
||||
|
||||
/*
|
||||
ITU Region 3 (Asia/Pacific) amateur 2m allocation: 144.000 - 148.000 MHz.
|
||||
Typical admin rules allow well above 30 dBm for licensed operators.
|
||||
|
||||
Default slot: 33 (144.650 MHz)
|
||||
https://www.iaru.org/wp-content/uploads/2020/01/R3-004-IARU-Region-3-Bandplan-rev.2.pdf
|
||||
https://www.wia.org.au/members/bandplans/data/documents/WIA%20Australian%20Band%20Plan%202026.pdf
|
||||
*/
|
||||
RDEF(ITU3_2M, 144.0f, 148.0f, 100, 30, false, false, PROFILE_HAM_20KHZ, PRESET(TINY_FAST), 33),
|
||||
|
||||
/*
|
||||
ITU Region 2 (Americas) amateur 1.25m '125cm' allocation: 220.000 - 225.000 MHz.
|
||||
Typical admin rules (e.g. US FCC Part 97) allow well above 30 dBm for licensed operators.
|
||||
Note: Some countries do not allocate 220-222 MHz (e.g. USA, Canada). Check local law!
|
||||
|
||||
Default slot: 37 (223.650 MHz)
|
||||
https://www.arrl.org/band-plan
|
||||
*/
|
||||
RDEF(ITU2_125CM, 220.0f, 225.0f, 100, 30, false, false, PROFILE_HAM_100KHZ, PRESET(NARROW_SLOW), 37),
|
||||
|
||||
/*
|
||||
2.4 GHZ WLAN Band equivalent. Only for SX128x chips.
|
||||
*/
|
||||
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD)
|
||||
Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm,
|
||||
duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123
|
||||
*/
|
||||
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST), 0),
|
||||
|
||||
/*
|
||||
EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz
|
||||
Channel centres at 869.442/869.525/869.608 MHz,
|
||||
10.4 kHz padding on channels, 27 dBm, duty cycle 10%
|
||||
*/
|
||||
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW), 1),
|
||||
|
||||
/*
|
||||
This needs to be last. Same as US.
|
||||
*/
|
||||
@@ -812,43 +855,116 @@ uint32_t RadioInterface::getChannelNum()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error-level client notification. Safe to call when service is null (e.g. in tests).
|
||||
* Send a client notification (error level unless specified). Safe to call when service is null (e.g. in tests).
|
||||
*/
|
||||
static void sendErrorNotification(const char *msg)
|
||||
static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level level = meshtastic_LogRecord_Level_ERROR)
|
||||
{
|
||||
if (!service)
|
||||
return;
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
if (!cn)
|
||||
return;
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->level = level;
|
||||
snprintf(cn->message, sizeof(cn->message), "%s", msg);
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
|
||||
// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset
|
||||
// locked to a sibling means the user wants that sibling region, not the default preset.
|
||||
static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_868,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_866,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a region is valid for the current settings.
|
||||
* If currentRegion is one of the swappable EU regions and preset belongs to a sibling in
|
||||
* that trio, return the sibling region that owns the preset. Returns nullptr otherwise.
|
||||
*/
|
||||
const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset preset)
|
||||
{
|
||||
bool currentIsSwappable = false;
|
||||
for (auto code : SWAPPABLE_EU_REGIONS) {
|
||||
if (code == currentRegion)
|
||||
currentIsSwappable = true;
|
||||
}
|
||||
if (!currentIsSwappable)
|
||||
return nullptr;
|
||||
|
||||
for (auto code : SWAPPABLE_EU_REGIONS) {
|
||||
if (code == currentRegion)
|
||||
continue;
|
||||
const RegionInfo *sibling = getRegion(code);
|
||||
if (sibling->supportsPreset(preset))
|
||||
return sibling;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a region is valid for the current settings, with no side effects.
|
||||
* Safe to call speculatively (e.g. from UI pickers). When errBuf is given, it
|
||||
* receives the human-readable failure reason.
|
||||
* Returns false if not compatible.
|
||||
*/
|
||||
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
|
||||
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen)
|
||||
{
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
|
||||
// Reject unrecognized region codes (getRegion returns UNSET sentinel for unknown codes)
|
||||
if (newRegion->code != loraConfig.region) {
|
||||
if (errBuf)
|
||||
snprintf(errBuf, errLen, "Region code %d is not recognized", loraConfig.region);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If you are not licensed, you can't use ham regions.
|
||||
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
|
||||
char err_string[160];
|
||||
snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
if (errBuf)
|
||||
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hardware compatibility: wide-LoRa (2.4 GHz) regions need a wide-capable radio, and
|
||||
// sub-GHz regions need a radio that can tune below 2.4 GHz (SX128x cannot). UNSET is
|
||||
// always allowed since it is the "no region" state.
|
||||
if (newRegion->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && RadioLibInterface::instance) {
|
||||
const char *unsupported = nullptr;
|
||||
if (newRegion->wideLora && !RadioLibInterface::instance->wideLora()) {
|
||||
unsupported = "2.4 GHz";
|
||||
} else if (!newRegion->wideLora && !RadioLibInterface::instance->supportsSubGhz()) {
|
||||
unsupported = "sub-GHz";
|
||||
}
|
||||
if (unsupported) {
|
||||
if (errBuf)
|
||||
snprintf(errBuf, errLen, "Region %s needs %s, which this radio does not support", newRegion->name, unsupported);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: validate or clamp a LoRa config against its region.
|
||||
* Checks if a region is valid for the current settings. On failure, logs at ERROR,
|
||||
* records a critical error, and sends a client notification.
|
||||
* Returns false if not compatible.
|
||||
*/
|
||||
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
|
||||
{
|
||||
char err_string[160];
|
||||
if (checkConfigRegion(loraConfig, err_string, sizeof(err_string)))
|
||||
return true;
|
||||
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: check or clamp a LoRa config against its region.
|
||||
* When clamp==false, returns false on first error (pure validation).
|
||||
* When clamp==true, fixes invalid settings in-place and returns true.
|
||||
*/
|
||||
@@ -865,11 +981,29 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
|
||||
if (loraConfig.use_preset) {
|
||||
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
|
||||
|
||||
bool preset_valid = false;
|
||||
for (size_t i = 0; i < newRegion->getNumPresets(); i++) {
|
||||
if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) {
|
||||
bool preset_valid = newRegion->supportsPreset(loraConfig.modem_preset);
|
||||
if (!preset_valid) {
|
||||
// A preset locked to a sibling of the swappable EU regions swaps the region instead
|
||||
// of clamping the preset, as long as the previous region was itself one of the trio.
|
||||
const RegionInfo *swapRegion = regionSwapForPreset(loraConfig.region, loraConfig.modem_preset);
|
||||
if (swapRegion) {
|
||||
if (!clamp) {
|
||||
// Validation must still fail so callers route into the clamp, but quietly:
|
||||
// the clamp will accept this config by swapping regions, so don't record a
|
||||
// critical error or alarm the user over a change that is about to succeed.
|
||||
LOG_INFO("Preset %s implies region swap %s to %s, deferring to clamp", presetName, newRegion->name,
|
||||
swapRegion->name);
|
||||
return false;
|
||||
}
|
||||
snprintf(err_string, sizeof(err_string), "Preset %s swaps region %s to %s", presetName, newRegion->name,
|
||||
swapRegion->name);
|
||||
LOG_INFO("%s", err_string);
|
||||
sendErrorNotification(err_string, meshtastic_LogRecord_Level_INFO);
|
||||
|
||||
loraConfig.region = swapRegion->code;
|
||||
newRegion = swapRegion;
|
||||
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
|
||||
preset_valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!preset_valid) {
|
||||
@@ -1233,4 +1367,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p)
|
||||
|
||||
sendingPacket = p;
|
||||
return p->encrypted.size + sizeof(PacketHeader);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,9 @@ class RadioInterface
|
||||
|
||||
virtual ~RadioInterface() {}
|
||||
|
||||
/// Fires once per valid received LoRa packet (arg = sender NodeNum). Used e.g. to flash LED_LORA.
|
||||
static Observable<uint32_t> loraRxPacketObservable;
|
||||
|
||||
/**
|
||||
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
|
||||
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
|
||||
@@ -143,6 +146,10 @@ class RadioInterface
|
||||
|
||||
virtual bool wideLora() { return false; }
|
||||
|
||||
/// Whether the radio can tune sub-GHz bands. False for 2.4 GHz-only chips (SX128x);
|
||||
/// multiband chips like the LR1121 keep the default.
|
||||
virtual bool supportsSubGhz() { return true; }
|
||||
|
||||
/// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep.
|
||||
virtual bool sleep() { return true; }
|
||||
|
||||
@@ -244,7 +251,12 @@ class RadioInterface
|
||||
|
||||
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
|
||||
|
||||
// Check if a candidate region is compatible and valid.
|
||||
// Check if a candidate region is compatible and valid, with no side effects (safe for
|
||||
// speculative UI checks). errBuf, if given, receives the failure reason.
|
||||
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0);
|
||||
|
||||
// Check if a candidate region is compatible and valid. On failure, logs at ERROR,
|
||||
// records a critical error, and sends a client notification.
|
||||
static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
// Check if a candidate radio configuration is valid.
|
||||
@@ -253,6 +265,11 @@ class RadioInterface
|
||||
// Make a candidate radio configuration valid, even if it isn't.
|
||||
static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
// If preset is locked to a sibling of currentRegion among the swappable EU regions
|
||||
// (EU_868/EU_866/EU_N_868), return the sibling region owning the preset, else nullptr.
|
||||
static const RegionInfo *regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset preset);
|
||||
|
||||
protected:
|
||||
int8_t power = 17; // Set by applyModemConfig()
|
||||
|
||||
|
||||
@@ -614,6 +614,10 @@ void RadioLibInterface::handleReceiveInterrupt()
|
||||
|
||||
printPacket("Lora RX", mp);
|
||||
|
||||
#ifdef LED_LORA
|
||||
loraRxPacketObservable.notifyObservers(mp->from);
|
||||
#endif
|
||||
|
||||
airTime->logAirtime(RX_LOG, rxMsec);
|
||||
|
||||
deliverToReceiver(mp);
|
||||
|
||||
+90
-23
@@ -15,6 +15,9 @@
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
#endif
|
||||
@@ -22,8 +25,6 @@
|
||||
#if ARCH_PORTDUINO
|
||||
#include "Throttle.h"
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#endif
|
||||
#if ENABLE_JSON_LOGGING || ARCH_PORTDUINO
|
||||
#include "serialization/MeshPacketSerializer.h"
|
||||
#endif
|
||||
|
||||
@@ -357,6 +358,30 @@ ErrorCode Router::send(meshtastic_MeshPacket *p)
|
||||
p->from = getFrom(p);
|
||||
|
||||
p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // set the relayer to us
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
// Apply HopScaling hop recommendation to routine outgoing broadcasts
|
||||
if (isFromUs(p) && isBroadcast(p->to) && hopScalingModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
|
||||
switch (p->decoded.portnum) {
|
||||
case meshtastic_PortNum_POSITION_APP:
|
||||
case meshtastic_PortNum_TELEMETRY_APP:
|
||||
case meshtastic_PortNum_NODEINFO_APP:
|
||||
case meshtastic_PortNum_NEIGHBORINFO_APP: {
|
||||
uint8_t variableHopLimit = hopScalingModule->getLastRequiredHop();
|
||||
|
||||
// Never exceed user-configured hop_limit
|
||||
if (variableHopLimit < p->hop_limit) {
|
||||
LOG_DEBUG("[HOPSCALE] hop_limit %u -> %u for portnum %u", p->hop_limit, variableHopLimit, p->decoded.portnum);
|
||||
p->hop_limit = variableHopLimit;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// If we are the original transmitter, set the hop limit with which we start
|
||||
if (isFromUs(p))
|
||||
p->hop_start = p->hop_limit;
|
||||
@@ -460,14 +485,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
bool decrypted = false;
|
||||
ChannelIndex chIndex = 0;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// Attempt PKI decryption first
|
||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->getMeshNode(p->from) != nullptr &&
|
||||
nodeDB->getMeshNode(p->from)->public_key.size > 0 && nodeDB->getMeshNode(p->to) != nullptr &&
|
||||
nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) {
|
||||
// Attempt PKI decryption first. The sender's key may come from the hot
|
||||
// store or the warm tier (nodes evicted from the hot store keep their key
|
||||
// there), so DMs from long-tail nodes still decrypt.
|
||||
meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}};
|
||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) &&
|
||||
nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 &&
|
||||
rawSize > MESHTASTIC_PKC_OVERHEAD) {
|
||||
LOG_DEBUG("Attempt PKI decryption");
|
||||
|
||||
if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->public_key, p->id, rawSize, p->encrypted.bytes,
|
||||
bytes)) {
|
||||
if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
LOG_INFO("PKI Decryption worked!");
|
||||
|
||||
meshtastic_Data decodedtmp;
|
||||
@@ -478,7 +505,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
decrypted = true;
|
||||
LOG_INFO("Packet decrypted using PKI!");
|
||||
p->pki_encrypted = true;
|
||||
memcpy(&p->public_key.bytes, nodeDB->getMeshNode(p->from)->public_key.bytes, 32);
|
||||
memcpy(p->public_key.bytes, fromKey.bytes, 32);
|
||||
p->public_key.size = 32;
|
||||
p->decoded = decodedtmp;
|
||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
|
||||
@@ -534,6 +561,38 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
if (p->decoded.has_bitfield)
|
||||
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && node->public_key.size == 32) {
|
||||
p->xeddsa_signed =
|
||||
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
|
||||
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
|
||||
if (p->xeddsa_signed) {
|
||||
// Mark this node as a signer so future unsigned packets from it are rejected
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
|
||||
} else {
|
||||
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
} else {
|
||||
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
|
||||
}
|
||||
} else {
|
||||
// Unsigned packet — only reject the class of packet a signing node always signs:
|
||||
// an unencrypted broadcast small enough to also carry a signature (see perhapsEncode()).
|
||||
// Unicast packets and oversized broadcasts are never signed, so they must not be
|
||||
// hard-failed here even if this node has signed before.
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && nodeInfoLiteHasXeddsaSigned(node) && isBroadcast(p->to) &&
|
||||
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Not actually ever used.
|
||||
// Decompress if needed. jm
|
||||
if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) {
|
||||
@@ -555,9 +614,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
} */
|
||||
|
||||
printPacket("decoded message", p);
|
||||
#if ENABLE_JSON_LOGGING
|
||||
LOG_TRACE("%s", MeshPacketSerializer::JsonSerialize(p, false).c_str());
|
||||
#elif ARCH_PORTDUINO
|
||||
#if ARCH_PORTDUINO
|
||||
if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) {
|
||||
LOG_TRACE("%s", MeshPacketSerializer::JsonSerialize(p, false).c_str());
|
||||
} else if (portduino_config.JSONFilename != "") {
|
||||
@@ -606,6 +663,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
p->decoded.has_bitfield = true;
|
||||
p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT);
|
||||
p->decoded.bitfield |= (p->decoded.want_response << BITFIELD_WANT_RESPONSE_SHIFT);
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
// Sign broadcast packets if payload + signature fits within the max Data payload.
|
||||
// The actual encoded size is checked after pb_encode (TOO_LARGE).
|
||||
if (!p->pki_encrypted && isBroadcast(p->to) &&
|
||||
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
|
||||
p->decoded.xeddsa_signature.bytes)) {
|
||||
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
||||
LOG_DEBUG("XEdDSA signed packet 0x%08x", p->id);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
size_t numbytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_Data_msg, &p->decoded);
|
||||
@@ -653,7 +722,10 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to);
|
||||
// Destination key from the hot store or the warm tier (evicted
|
||||
// long-tail nodes keep their key there)
|
||||
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
|
||||
bool haveDestKey = nodeDB->copyPublicKey(p->to, destKey);
|
||||
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
|
||||
// is not in the local nodedb
|
||||
// First, only PKC encrypt packets we are originating
|
||||
@@ -676,18 +748,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
||||
return meshtastic_Routing_Error_TOO_LARGE;
|
||||
// Check for a known public key for the destination
|
||||
if (node == nullptr || node->public_key.size != 32) {
|
||||
if (!haveDestKey) {
|
||||
LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to,
|
||||
p->decoded.portnum);
|
||||
return meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY;
|
||||
}
|
||||
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) &&
|
||||
memcmp(p->public_key.bytes, node->public_key.bytes, 32) != 0) {
|
||||
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) && memcmp(p->public_key.bytes, destKey.bytes, 32) != 0) {
|
||||
LOG_WARN("Client public key differs from requested: 0x%02x, stored key begins 0x%02x", *p->public_key.bytes,
|
||||
*node->public_key.bytes);
|
||||
*destKey.bytes);
|
||||
return meshtastic_Routing_Error_PKI_FAILED;
|
||||
}
|
||||
crypto->encryptCurve25519(p->to, getFrom(p), node->public_key, p->id, numbytes, bytes, p->encrypted.bytes);
|
||||
crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes);
|
||||
numbytes += MESHTASTIC_PKC_OVERHEAD;
|
||||
p->channel = 0;
|
||||
p->pki_encrypted = true;
|
||||
@@ -852,11 +923,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
||||
|
||||
void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
||||
{
|
||||
#if ENABLE_JSON_LOGGING
|
||||
// Even ignored packets get logged in the trace
|
||||
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
|
||||
LOG_TRACE("%s", MeshPacketSerializer::JsonSerializeEncrypted(p).c_str());
|
||||
#elif ARCH_PORTDUINO
|
||||
#if ARCH_PORTDUINO
|
||||
// Even ignored packets get logged in the trace
|
||||
if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) {
|
||||
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
|
||||
|
||||
@@ -35,6 +35,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory
|
||||
*/
|
||||
void addInterface(std::unique_ptr<RadioInterface> _iface) { iface = std::move(_iface); }
|
||||
|
||||
/**
|
||||
* Borrowed (non-owning) access to the radio interface — used by NodeDB
|
||||
* after a lockdown unlock so it can push the freshly-loaded config to
|
||||
* the SX12xx via reconfigure(). Returns nullptr when no radio has been
|
||||
* attached (e.g. ARCH_PORTDUINO simulator before SimRadio bind).
|
||||
*/
|
||||
RadioInterface *getRadioIface() { return iface.get(); }
|
||||
|
||||
/**
|
||||
* do idle processing
|
||||
* Mostly looking in our incoming rxPacket queue and calling handleReceived.
|
||||
|
||||
@@ -19,6 +19,9 @@ template <class T> class SX128xInterface : public RadioLibInterface
|
||||
|
||||
virtual bool wideLora() override;
|
||||
|
||||
/// SX128x is a 2.4 GHz-only chip; it cannot tune sub-GHz regions
|
||||
virtual bool supportsSubGhz() override { return false; }
|
||||
|
||||
/// Apply any radio provisioning changes
|
||||
/// Make sure the Driver is properly configured before calling init().
|
||||
/// \return true if initialisation succeeded.
|
||||
|
||||
@@ -18,6 +18,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo
|
||||
info.is_ignored = nodeInfoLiteIsIgnored(lite);
|
||||
info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite);
|
||||
info.is_muted = nodeInfoLiteIsMuted(lite);
|
||||
info.has_xeddsa_signed = nodeInfoLiteHasXeddsaSigned(lite);
|
||||
|
||||
if (lite->has_hops_away) {
|
||||
info.has_hops_away = true;
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
#include "WarmNodeStore.h"
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
#include "FSCommon.h"
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "configuration.h"
|
||||
#include "power/PowerHAL.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <vector>
|
||||
|
||||
#if defined(NRF52840_XXAA)
|
||||
#include "flash/flash_nrf5x.h"
|
||||
#define WARM_RING_MAGIC 0x474E5257u // "WRNG"
|
||||
// A tombstone is an entry record whose last_heard is all-ones — getTime()
|
||||
// (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is
|
||||
// detected via num == 0xFFFFFFFF before last_heard is ever inspected.
|
||||
#define WARM_RING_TOMBSTONE 0xFFFFFFFFu
|
||||
#else
|
||||
// warm.dat layout: this header followed by count packed WarmNodeEntry records.
|
||||
struct WarmStoreHeader {
|
||||
uint32_t magic; // WARM_STORE_MAGIC
|
||||
uint32_t reserved; // 0; kept so the header stays 16 B
|
||||
uint16_t count; // entries persisted
|
||||
uint16_t entrySize; // sizeof(WarmNodeEntry), format guard
|
||||
uint32_t crc; // crc32 over count * entrySize bytes
|
||||
};
|
||||
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");
|
||||
|
||||
#define WARM_STORE_MAGIC 0x314D5257u // "WRM1"
|
||||
|
||||
#ifdef FSCom
|
||||
static const char *warmFileName = "/prefs/warm.dat";
|
||||
#endif
|
||||
#endif // NRF52840_XXAA
|
||||
|
||||
static inline bool keyIsSet(const uint8_t key[32])
|
||||
{
|
||||
for (int i = 0; i < 32; i++)
|
||||
if (key[i])
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
WarmNodeStore::WarmNodeStore()
|
||||
{
|
||||
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
|
||||
entries = static_cast<WarmNodeEntry *>(ps_calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
|
||||
if (!entries) {
|
||||
LOG_WARN("WarmStore: PSRAM alloc failed, using heap");
|
||||
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
|
||||
}
|
||||
#else
|
||||
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
|
||||
#endif
|
||||
#if defined(NRF52840_XXAA)
|
||||
memset(pageOf, kNoPage, sizeof(pageOf));
|
||||
#endif
|
||||
}
|
||||
|
||||
WarmNodeStore::~WarmNodeStore()
|
||||
{
|
||||
free(entries); // always malloc-family (calloc / ps_calloc)
|
||||
entries = nullptr;
|
||||
}
|
||||
|
||||
WarmNodeEntry *WarmNodeStore::find(NodeNum num) const
|
||||
{
|
||||
if (!entries || !num)
|
||||
return nullptr;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (entries[i].num == num)
|
||||
return &entries[i];
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Slot placement with the keyed-first admission policy. Shared by absorb()
|
||||
// and the ring replay, so the policy is applied identically in both paths.
|
||||
WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8_t *key32)
|
||||
{
|
||||
if (!entries || !num)
|
||||
return nullptr;
|
||||
|
||||
const bool candidateKeyed = key32 && keyIsSet(key32);
|
||||
|
||||
WarmNodeEntry *slot = find(num);
|
||||
const bool sameNode = slot != nullptr;
|
||||
if (!slot) {
|
||||
// Pick a victim: any empty slot, else the oldest keyless entry, else
|
||||
// (only for keyed candidates) the oldest keyed entry.
|
||||
WarmNodeEntry *oldestKeyless = nullptr, *oldestKeyed = nullptr;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
|
||||
WarmNodeEntry &e = entries[i];
|
||||
if (!e.num) {
|
||||
slot = &e;
|
||||
break;
|
||||
}
|
||||
if (keyIsSet(e.public_key)) {
|
||||
if (!oldestKeyed || e.last_heard < oldestKeyed->last_heard)
|
||||
oldestKeyed = &e;
|
||||
} else {
|
||||
if (!oldestKeyless || e.last_heard < oldestKeyless->last_heard)
|
||||
oldestKeyless = &e;
|
||||
}
|
||||
}
|
||||
if (!slot)
|
||||
slot = oldestKeyless ? oldestKeyless : (candidateKeyed ? oldestKeyed : nullptr);
|
||||
if (!slot)
|
||||
return nullptr; // store full of keyed entries and the candidate has no key
|
||||
}
|
||||
|
||||
slot->num = num;
|
||||
slot->last_heard = lastHeard;
|
||||
if (candidateKeyed)
|
||||
memcpy(slot->public_key, key32, 32);
|
||||
else if (!sameNode)
|
||||
// Repurposing a victim slot for a different node: clear its stale key.
|
||||
// A keyless refresh of a node already here keeps the key we learned.
|
||||
memset(slot->public_key, 0, 32);
|
||||
return slot;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32)
|
||||
{
|
||||
const WarmNodeEntry *slot = place(num, lastHeard, key32);
|
||||
if (!slot)
|
||||
return false;
|
||||
persistEntry(*slot);
|
||||
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u (now %u/%u)", (unsigned)num, keyIsSet(slot->public_key) ? 1 : 0,
|
||||
(unsigned)lastHeard, (unsigned)count(), (unsigned)capacity());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
|
||||
{
|
||||
WarmNodeEntry *e = find(num);
|
||||
if (!e)
|
||||
return false;
|
||||
out = *e;
|
||||
const int idx = static_cast<int>(e - entries);
|
||||
memset(e, 0, sizeof(*e));
|
||||
persistRemove(num, idx);
|
||||
LOG_MIGRATION("WarmStore take(rehydrate) 0x%08x key=%d (now %u/%u)", (unsigned)num, keyIsSet(out.public_key) ? 1 : 0,
|
||||
(unsigned)count(), (unsigned)capacity());
|
||||
return true;
|
||||
}
|
||||
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
void WarmNodeStore::dumpToLog(const char *reason) const
|
||||
{
|
||||
if (!entries) {
|
||||
LOG_MIGRATION("WarmStore dump (%s): backend not allocated", reason);
|
||||
return;
|
||||
}
|
||||
LOG_MIGRATION("WarmStore dump (%s): %u live / %u cap ==>", reason, (unsigned)count(), (unsigned)capacity());
|
||||
unsigned shown = 0;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
|
||||
const WarmNodeEntry &e = entries[i];
|
||||
if (e.num == 0)
|
||||
continue;
|
||||
LOG_MIGRATION(" warm[%3u] 0x%08x last_heard=%u key=%d", (unsigned)i, (unsigned)e.num, (unsigned)e.last_heard,
|
||||
keyIsSet(e.public_key) ? 1 : 0);
|
||||
shown++;
|
||||
}
|
||||
LOG_MIGRATION("WarmStore dump (%s): <== end (%u entries)", reason, shown);
|
||||
}
|
||||
#endif // MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
|
||||
bool WarmNodeStore::copyKey(NodeNum num, uint8_t out[32]) const
|
||||
{
|
||||
const WarmNodeEntry *e = find(num);
|
||||
if (!e || !keyIsSet(e->public_key))
|
||||
return false;
|
||||
memcpy(out, e->public_key, 32);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::contains(NodeNum num) const
|
||||
{
|
||||
return find(num) != nullptr;
|
||||
}
|
||||
|
||||
void WarmNodeStore::remove(NodeNum num)
|
||||
{
|
||||
WarmNodeEntry *e = find(num);
|
||||
if (e) {
|
||||
const int idx = static_cast<int>(e - entries);
|
||||
memset(e, 0, sizeof(*e));
|
||||
persistRemove(num, idx);
|
||||
}
|
||||
}
|
||||
|
||||
void WarmNodeStore::clear()
|
||||
{
|
||||
if (!entries)
|
||||
return;
|
||||
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
|
||||
#if defined(NRF52840_XXAA)
|
||||
memset(pageOf, kNoPage, sizeof(pageOf));
|
||||
#endif
|
||||
persistClear();
|
||||
}
|
||||
|
||||
size_t WarmNodeStore::count() const
|
||||
{
|
||||
size_t n = 0;
|
||||
if (entries)
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (entries[i].num)
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::saveIfDirty()
|
||||
{
|
||||
if (!dirty)
|
||||
return true;
|
||||
bool ok = save();
|
||||
if (ok)
|
||||
dirty = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
#if defined(NRF52840_XXAA)
|
||||
|
||||
// Raw-flash record-ring backend (nRF52840).
|
||||
// 3 × 4 KB pages below LittleFS. Mutations append 40 B records (entry snapshot,
|
||||
// or tombstone with last_heard == 0xFFFFFFFF) via the shared flash_nrf5x page
|
||||
// cache; saveIfDirty() is the durability point. A full page reclaims the oldest
|
||||
// (stranded live entries re-appended, then erased). Flash access holds spiLock —
|
||||
// the page cache is shared with InternalFS/LittleFS.
|
||||
|
||||
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h) const
|
||||
{
|
||||
flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h));
|
||||
return h.magic == WARM_RING_MAGIC && h.seq != 0xFFFFFFFFu;
|
||||
}
|
||||
|
||||
// Caller holds spiLock.
|
||||
void WarmNodeStore::ringOpenPage(uint8_t page)
|
||||
{
|
||||
// Drop any cached state for the page before the real erase, so a later
|
||||
// cache flush can't resurrect stale bytes.
|
||||
flash_nrf5x_flush();
|
||||
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(page));
|
||||
WarmPageHeader h;
|
||||
h.magic = WARM_RING_MAGIC;
|
||||
h.seq = nextSeq++;
|
||||
flash_nrf5x_write(WARM_FLASH_PAGE_ADDR(page), &h, sizeof(h));
|
||||
activePage = page;
|
||||
writeSlot = 0;
|
||||
}
|
||||
|
||||
// Caller holds spiLock. May recurse once via ringAppend if the stranded set
|
||||
// fills the fresh page exactly — bounded by WARM_NODE_COUNT <= 2*kRecordsPerPage.
|
||||
void WarmNodeStore::ringRotate()
|
||||
{
|
||||
uint8_t target = 0;
|
||||
if (activePage != kNoPage) {
|
||||
// Lowest-seq valid page, preferring erased pages; never the active one
|
||||
uint32_t bestSeq = 0;
|
||||
bool found = false;
|
||||
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
|
||||
if (p == activePage)
|
||||
continue;
|
||||
WarmPageHeader h;
|
||||
if (!ringReadHeader(p, h)) {
|
||||
target = p; // erased/invalid page: free real estate, take it
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (!found || static_cast<int32_t>(h.seq - bestSeq) < 0) {
|
||||
target = p;
|
||||
bestSeq = h.seq;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture live entries stranded in the page we're about to erase
|
||||
int stranded[WARM_NODE_COUNT] = {};
|
||||
int nStranded = 0;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
|
||||
if (entries[i].num && pageOf[i] == target)
|
||||
stranded[nStranded++] = static_cast<int>(i);
|
||||
if (pageOf[i] == target)
|
||||
pageOf[i] = kNoPage;
|
||||
}
|
||||
|
||||
ringOpenPage(target);
|
||||
|
||||
for (int k = 0; k < nStranded; k++)
|
||||
ringAppend(entries[stranded[k]], stranded[k]);
|
||||
}
|
||||
|
||||
// Caller holds spiLock.
|
||||
void WarmNodeStore::ringAppend(const WarmNodeEntry &rec, int storeSlot)
|
||||
{
|
||||
if (activePage == kNoPage || writeSlot >= kRecordsPerPage)
|
||||
ringRotate();
|
||||
const uint32_t addr =
|
||||
WARM_FLASH_PAGE_ADDR(activePage) + sizeof(WarmPageHeader) + static_cast<uint32_t>(writeSlot) * sizeof(WarmNodeEntry);
|
||||
flash_nrf5x_write(addr, &rec, sizeof(rec));
|
||||
writeSlot++;
|
||||
if (storeSlot >= 0)
|
||||
pageOf[storeSlot] = activePage;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
ringAppend(e, static_cast<int>(&e - entries));
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
|
||||
{
|
||||
if (storeSlot >= 0 && storeSlot < static_cast<int>(WARM_NODE_COUNT))
|
||||
pageOf[storeSlot] = 0xFF;
|
||||
WarmNodeEntry tomb;
|
||||
memset(&tomb, 0, sizeof(tomb));
|
||||
tomb.num = num;
|
||||
tomb.last_heard = WARM_RING_TOMBSTONE;
|
||||
concurrency::LockGuard g(spiLock);
|
||||
ringAppend(tomb, -1);
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistClear()
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
flash_nrf5x_flush();
|
||||
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++)
|
||||
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(p));
|
||||
activePage = 0xFF;
|
||||
writeSlot = 0;
|
||||
nextSeq = 1;
|
||||
dirty = false; // the erased ring already reflects the empty store
|
||||
}
|
||||
|
||||
void WarmNodeStore::load()
|
||||
{
|
||||
if (!entries)
|
||||
return;
|
||||
concurrency::LockGuard g(spiLock);
|
||||
|
||||
// Order valid pages by ascending seq so replay applies oldest first
|
||||
uint8_t order[WARM_FLASH_PAGES] = {};
|
||||
uint32_t seqs[WARM_FLASH_PAGES] = {};
|
||||
uint8_t nValid = 0;
|
||||
uint8_t nCorrupt = 0;
|
||||
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
|
||||
WarmPageHeader h;
|
||||
if (!ringReadHeader(p, h)) {
|
||||
// An erased page reads back all-ones; any other magic is a
|
||||
// partially-written or bit-rotted header we're dropping, so flag it
|
||||
// rather than silently treating the loss as a clean empty ring.
|
||||
if (h.magic != 0xFFFFFFFFu)
|
||||
nCorrupt++;
|
||||
continue;
|
||||
}
|
||||
uint8_t pos = nValid;
|
||||
while (pos > 0 && static_cast<int32_t>(h.seq - seqs[pos - 1]) < 0) {
|
||||
order[pos] = order[pos - 1];
|
||||
seqs[pos] = seqs[pos - 1];
|
||||
pos--;
|
||||
}
|
||||
order[pos] = p;
|
||||
seqs[pos] = h.seq;
|
||||
nValid++;
|
||||
}
|
||||
|
||||
if (nValid == 0) {
|
||||
activePage = 0xFF;
|
||||
writeSlot = 0;
|
||||
nextSeq = 1;
|
||||
if (nCorrupt)
|
||||
LOG_WARN("WarmStore: ring unreadable (%u bad page(s)), empty", nCorrupt);
|
||||
else
|
||||
LOG_INFO("WarmStore: ring empty, starting fresh");
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t replayed = 0;
|
||||
for (uint8_t k = 0; k < nValid; k++) {
|
||||
const uint8_t p = order[k];
|
||||
uint16_t slot = 0;
|
||||
for (; slot < kRecordsPerPage; slot++) {
|
||||
WarmNodeEntry rec;
|
||||
flash_nrf5x_read(&rec, WARM_FLASH_PAGE_ADDR(p) + sizeof(WarmPageHeader) + (uint32_t)slot * sizeof(rec), sizeof(rec));
|
||||
if (rec.num == 0xFFFFFFFFu)
|
||||
break; // erased space: end of this page's records (append-only)
|
||||
if (rec.num == 0)
|
||||
continue; // unexpected; skip defensively
|
||||
replayed++;
|
||||
if (rec.last_heard == WARM_RING_TOMBSTONE) {
|
||||
WarmNodeEntry *e = find(rec.num);
|
||||
if (e) {
|
||||
pageOf[e - entries] = 0xFF;
|
||||
memset(e, 0, sizeof(*e));
|
||||
}
|
||||
} else {
|
||||
const WarmNodeEntry *e = place(rec.num, rec.last_heard, rec.public_key);
|
||||
if (e)
|
||||
pageOf[e - entries] = p;
|
||||
}
|
||||
}
|
||||
if (k == nValid - 1) { // newest page becomes the active head
|
||||
activePage = p;
|
||||
writeSlot = slot;
|
||||
nextSeq = seqs[k] + 1;
|
||||
}
|
||||
}
|
||||
if (nCorrupt)
|
||||
LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt);
|
||||
LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(),
|
||||
activePage, writeSlot);
|
||||
}
|
||||
|
||||
bool WarmNodeStore::save()
|
||||
{
|
||||
if (!powerHAL_isPowerLevelSafe()) {
|
||||
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
|
||||
return false;
|
||||
}
|
||||
concurrency::LockGuard g(spiLock);
|
||||
flash_nrf5x_flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
#else // !NRF52840_XXAA --------------------
|
||||
|
||||
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
|
||||
{
|
||||
(void)e;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
|
||||
{
|
||||
(void)num;
|
||||
(void)storeSlot;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistClear()
|
||||
{
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
#ifdef FSCom
|
||||
|
||||
// ---- File persistence: /prefs/warm.dat snapshots ----------------------------
|
||||
|
||||
// Compact occupied slots to the front of `dst`; returns the count.
|
||||
static uint16_t packEntries(const WarmNodeEntry *src, WarmNodeEntry *dst)
|
||||
{
|
||||
uint16_t n = 0;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (src[i].num)
|
||||
dst[n++] = src[i];
|
||||
return n;
|
||||
}
|
||||
|
||||
void WarmNodeStore::load()
|
||||
{
|
||||
if (!entries)
|
||||
return;
|
||||
// Clear first — all failure paths below then correctly represent "empty",
|
||||
// even if load() is called on an already-used instance.
|
||||
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
|
||||
concurrency::LockGuard g(spiLock);
|
||||
auto f = FSCom.open(warmFileName, FILE_O_READ);
|
||||
if (!f)
|
||||
return;
|
||||
WarmStoreHeader h;
|
||||
if ((size_t)f.read((uint8_t *)&h, sizeof(h)) != sizeof(h)) {
|
||||
f.close();
|
||||
LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName);
|
||||
return;
|
||||
}
|
||||
if (h.magic != WARM_STORE_MAGIC || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
|
||||
f.close();
|
||||
LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic,
|
||||
h.entrySize, h.count);
|
||||
return;
|
||||
}
|
||||
if (h.count) {
|
||||
const size_t len = (size_t)h.count * sizeof(WarmNodeEntry);
|
||||
const bool readOk = (size_t)f.read((uint8_t *)entries, len) == len;
|
||||
f.close();
|
||||
if (!readOk) {
|
||||
LOG_WARN("WarmStore: %s entries read failed, starting empty", warmFileName);
|
||||
return;
|
||||
}
|
||||
if (crc32Buffer(entries, len) != h.crc) {
|
||||
LOG_WARN("WarmStore: %s CRC mismatch, starting empty", warmFileName);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
f.close();
|
||||
}
|
||||
LOG_INFO("WarmStore: loaded %u warm nodes from %s", h.count, warmFileName);
|
||||
}
|
||||
|
||||
bool WarmNodeStore::save()
|
||||
{
|
||||
if (!entries)
|
||||
return false;
|
||||
if (!powerHAL_isPowerLevelSafe()) {
|
||||
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<WarmNodeEntry> packed(WARM_NODE_COUNT);
|
||||
WarmStoreHeader h;
|
||||
h.magic = WARM_STORE_MAGIC;
|
||||
h.reserved = 0;
|
||||
h.count = packEntries(entries, packed.data());
|
||||
h.entrySize = sizeof(WarmNodeEntry);
|
||||
h.crc = crc32Buffer(packed.data(), h.count * sizeof(WarmNodeEntry));
|
||||
|
||||
concurrency::LockGuard g(spiLock);
|
||||
FSCom.mkdir("/prefs");
|
||||
|
||||
auto f = SafeFile(warmFileName, false);
|
||||
f.write((const uint8_t *)&h, sizeof(h));
|
||||
f.write((const uint8_t *)packed.data(), h.count * sizeof(WarmNodeEntry));
|
||||
bool ok = f.close();
|
||||
if (!ok)
|
||||
LOG_ERROR("WarmStore: can't write %s", warmFileName);
|
||||
else
|
||||
LOG_DEBUG("WarmStore: saved %u warm nodes to %s", h.count, warmFileName);
|
||||
return ok;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void WarmNodeStore::load() {}
|
||||
bool WarmNodeStore::save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // FSCom
|
||||
#endif // NRF52840_XXAA
|
||||
|
||||
#endif // WARM_NODE_COUNT > 0
|
||||
@@ -0,0 +1,130 @@
|
||||
#pragma once
|
||||
|
||||
#include "MeshTypes.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// Verbose tracing for the warm-store migration + NodeDB self-care. Per-event /
|
||||
// per-boot chatter routes through this so it can be silenced in one place (set
|
||||
// to 0) once they're proven; genuine LOG_WARN anomalies stay unconditional.
|
||||
#ifndef MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
#define MESHTASTIC_NODEDB_MIGRATION_VERBOSE 0
|
||||
#endif
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
#define LOG_MIGRATION(...) LOG_INFO(__VA_ARGS__)
|
||||
#else
|
||||
#define LOG_MIGRATION(...) ((void)0)
|
||||
#endif
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
/**
|
||||
* Warm ("long-tail") node tier.
|
||||
*
|
||||
* Minimal identity record (NodeNum, last_heard, Curve25519 public key) for nodes
|
||||
* evicted from the hot NodeInfoLite store, so DMs to/from them keep encrypting —
|
||||
* the key is expensive to re-learn, the rest rebuilds from traffic in seconds.
|
||||
* Flat fixed array, linear scan (only on hot-store misses), LRU by last_heard
|
||||
* with keyed entries outranking keyless.
|
||||
*
|
||||
* Persistence: nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
|
||||
* (append + replay + compact-on-rotate — see the backend in WarmNodeStore.cpp,
|
||||
* link-guarded by nrf52840_s140_v7.ld). Everywhere else: /prefs/warm.dat.
|
||||
*/
|
||||
struct WarmNodeEntry {
|
||||
NodeNum num; // 0 = empty slot
|
||||
uint32_t last_heard; // recency for LRU ordering
|
||||
uint8_t public_key[32]; // all-zero = no key (a real key is never all-zero)
|
||||
};
|
||||
static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B — persistence format depends on it");
|
||||
|
||||
// Gated on NRF52840_XXAA: the ring sits at 0xEA000
|
||||
// valid only on the 1 MB-flash nRF52840.
|
||||
#if defined(NRF52840_XXAA)
|
||||
#define WARM_FLASH_PAGE_SIZE 4096u
|
||||
#define WARM_FLASH_PAGES 3u
|
||||
#define WARM_FLASH_REGION_BASE (0xED000u - WARM_FLASH_PAGES * WARM_FLASH_PAGE_SIZE) // 0xEA000
|
||||
#define WARM_FLASH_PAGE_ADDR(i) (WARM_FLASH_REGION_BASE + (i)*WARM_FLASH_PAGE_SIZE)
|
||||
#endif
|
||||
|
||||
class WarmNodeStore
|
||||
{
|
||||
public:
|
||||
WarmNodeStore();
|
||||
~WarmNodeStore();
|
||||
WarmNodeStore(const WarmNodeStore &) = delete;
|
||||
WarmNodeStore &operator=(const WarmNodeStore &) = delete;
|
||||
|
||||
/// Remember an evicted hot node. Keyless candidates never displace keyed
|
||||
/// entries; otherwise the oldest (keyless-first) entry is replaced.
|
||||
/// @return true if the node was stored or updated
|
||||
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */);
|
||||
|
||||
/// Find and remove an entry (used when the node is re-admitted to the hot store).
|
||||
bool take(NodeNum num, WarmNodeEntry &out);
|
||||
|
||||
/// Copy the 32-byte public key for a node, if we have one.
|
||||
bool copyKey(NodeNum num, uint8_t out[32]) const;
|
||||
|
||||
bool contains(NodeNum num) const;
|
||||
void remove(NodeNum num);
|
||||
void clear();
|
||||
size_t count() const;
|
||||
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
|
||||
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
|
||||
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.
|
||||
void dumpToLog(const char *reason = "dump") const;
|
||||
#endif
|
||||
|
||||
/// Load persisted entries (called once at boot, after the node DB loads).
|
||||
void load();
|
||||
/// Durability point, piggybacked on the node-database save cadence. On the
|
||||
/// ring backend this flushes the shared flash page cache; on the file
|
||||
/// backend it writes the warm.dat snapshot.
|
||||
bool saveIfDirty();
|
||||
|
||||
private:
|
||||
WarmNodeEntry *entries = nullptr; // WARM_NODE_COUNT slots; PSRAM on ESP32 when available
|
||||
bool dirty = false;
|
||||
|
||||
WarmNodeEntry *find(NodeNum num) const;
|
||||
// Internal slot-placement shared by absorb() and ring replay: applies the
|
||||
// keyed-first admission policy without touching persistence.
|
||||
WarmNodeEntry *place(NodeNum num, uint32_t lastHeard, const uint8_t *key32);
|
||||
|
||||
// Persistence hooks called from the mutation paths. File backend: mark
|
||||
// dirty. Ring backend: append an upsert/tombstone record (+ mark dirty).
|
||||
void persistEntry(const WarmNodeEntry &e); // e must point into entries[]
|
||||
void persistRemove(NodeNum num, int storeSlot);
|
||||
void persistClear();
|
||||
|
||||
#if defined(NRF52840_XXAA)
|
||||
// nRF52840 raw-flash record-ring state.
|
||||
struct WarmPageHeader {
|
||||
uint32_t magic; // WARM_RING_MAGIC
|
||||
uint32_t seq; // page generation; 0xFFFFFFFF = erased/unused
|
||||
};
|
||||
static_assert(sizeof(WarmPageHeader) == 8, "page header is part of the flash format");
|
||||
static constexpr uint16_t kRecordsPerPage = (WARM_FLASH_PAGE_SIZE - sizeof(WarmPageHeader)) / sizeof(WarmNodeEntry); // 102
|
||||
static_assert(WARM_NODE_COUNT <= 2 * ((WARM_FLASH_PAGE_SIZE - 8) / 40), "live set must fit the ring with one page reclaimed");
|
||||
|
||||
static constexpr uint8_t kNoPage = 0xFF; // "no page" sentinel for activePage / pageOf[]
|
||||
|
||||
uint8_t activePage = kNoPage; // no page opened yet (fresh/erased ring)
|
||||
uint16_t writeSlot = 0; // next free record slot in the active page
|
||||
uint32_t nextSeq = 1; // seq for the next page opened
|
||||
uint8_t pageOf[WARM_NODE_COUNT]; // flash page holding each RAM slot's newest record; kNoPage = none
|
||||
|
||||
void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */);
|
||||
void ringRotate(); // reclaim oldest page, compacting stranded live entries
|
||||
void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++)
|
||||
bool ringReadHeader(uint8_t page, WarmPageHeader &h) const;
|
||||
#endif
|
||||
|
||||
bool save();
|
||||
};
|
||||
|
||||
#endif // WARM_NODE_COUNT > 0
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "main.h"
|
||||
#include "mesh/api/ethServerAPI.h"
|
||||
#include "target_specific.h"
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
#include "mesh/eth/ethOTA.h"
|
||||
#endif
|
||||
#ifdef USE_ARDUINO_ETHERNET
|
||||
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5100/W5200/W5500
|
||||
// Shorter DHCP timeout so LoRa startup isn't blocked when no DHCP server is present.
|
||||
@@ -154,6 +157,10 @@ static int32_t reconnectETH()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
initEthOTA();
|
||||
#endif
|
||||
|
||||
ethStartupComplete = true;
|
||||
}
|
||||
}
|
||||
@@ -180,6 +187,10 @@ static int32_t reconnectETH()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
ethOTALoop();
|
||||
#endif
|
||||
|
||||
return 5000; // every 5 seconds
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
#include "configuration.h"
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
|
||||
#include "ethOTA.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <SHA256.h>
|
||||
#include <Updater.h>
|
||||
#ifdef ARCH_RP2040
|
||||
#include <hardware/watchdog.h>
|
||||
#define FEED_WATCHDOG() watchdog_update()
|
||||
#else
|
||||
#define FEED_WATCHDOG() ((void)0)
|
||||
#endif
|
||||
|
||||
/// Protocol header sent by the upload tool
|
||||
struct __attribute__((packed)) OTAHeader {
|
||||
uint8_t magic[4]; // "MOTA" (Meshtastic OTA)
|
||||
uint32_t firmwareSize; // Size of the firmware payload in bytes (little-endian)
|
||||
uint32_t crc32; // CRC32 of the entire firmware payload
|
||||
};
|
||||
|
||||
/// Response codes sent back to the client
|
||||
enum OTAResponse : uint8_t {
|
||||
OTA_OK = 0x00,
|
||||
OTA_ERR_CRC = 0x01,
|
||||
OTA_ERR_SIZE = 0x02,
|
||||
OTA_ERR_WRITE = 0x03,
|
||||
OTA_ERR_MAGIC = 0x04,
|
||||
OTA_ERR_BEGIN = 0x05,
|
||||
OTA_ACK = 0x06, // ACK uses ASCII ACK character
|
||||
OTA_ERR_AUTH = 0x07,
|
||||
OTA_ERR_TIMEOUT = 0x08,
|
||||
};
|
||||
|
||||
static const uint32_t OTA_TIMEOUT_MS = 30000; // 30s inactivity timeout
|
||||
static const size_t OTA_CHUNK_SIZE = 1024; // 1KB receive buffer
|
||||
static const uint32_t OTA_AUTH_COOLDOWN_MS = 5000; // 5s cooldown after failed auth
|
||||
static const size_t OTA_NONCE_SIZE = 32;
|
||||
static const size_t OTA_HASH_SIZE = 32;
|
||||
|
||||
// OTA PSK — override via USERPREFS_OTA_PSK in userPrefs.jsonc
|
||||
// USERPREFS_OTA_PSK is stringified by PlatformIO (wrapped in quotes), so we
|
||||
// use a char[] and sizeof-1 to exclude the trailing NUL byte from the hash.
|
||||
#ifdef USERPREFS_OTA_PSK
|
||||
static const char otaPSKString[] = USERPREFS_OTA_PSK;
|
||||
static const uint8_t *const otaPSK = reinterpret_cast<const uint8_t *>(otaPSKString);
|
||||
static const size_t otaPSKSize = sizeof(otaPSKString) - 1;
|
||||
#else
|
||||
// Default PSK (CHANGE THIS for production deployments)
|
||||
static const uint8_t otaPSK[] = {0x6d, 0x65, 0x73, 0x68, 0x74, 0x61, 0x73, 0x74, 0x69, 0x63, 0x5f, 0x6f, 0x74, 0x61, 0x5f, 0x64,
|
||||
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x70, 0x73, 0x6b, 0x5f, 0x76, 0x31, 0x21, 0x21, 0x21};
|
||||
// = "meshtastic_ota_default_psk_v1!!!"
|
||||
static const size_t otaPSKSize = sizeof(otaPSK);
|
||||
#endif
|
||||
|
||||
static EthernetServer *otaServer = nullptr;
|
||||
static uint32_t lastAuthFailure = 0;
|
||||
|
||||
static bool readExact(EthernetClient &client, uint8_t *buf, size_t len)
|
||||
{
|
||||
size_t received = 0;
|
||||
uint32_t lastActivity = millis();
|
||||
|
||||
while (received < len) {
|
||||
if (!client.connected()) {
|
||||
return false;
|
||||
}
|
||||
int avail = client.available();
|
||||
if (avail > 0) {
|
||||
size_t toRead = min((size_t)avail, len - received);
|
||||
size_t got = client.read(buf + received, toRead);
|
||||
received += got;
|
||||
lastActivity = millis();
|
||||
} else {
|
||||
if (millis() - lastActivity > OTA_TIMEOUT_MS) {
|
||||
return false;
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
FEED_WATCHDOG();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Compute SHA256(nonce || psk) for challenge-response authentication
|
||||
static void computeAuthHash(const uint8_t *nonce, size_t nonceLen, const uint8_t *psk, size_t pskLen, uint8_t *hashOut)
|
||||
{
|
||||
SHA256 sha;
|
||||
sha.reset();
|
||||
sha.update(nonce, nonceLen);
|
||||
sha.update(psk, pskLen);
|
||||
sha.finalize(hashOut, OTA_HASH_SIZE);
|
||||
}
|
||||
|
||||
/// Challenge-response authentication. Returns true if client is authenticated.
|
||||
static bool authenticateClient(EthernetClient &client)
|
||||
{
|
||||
// Rate-limit after failed auth — close silently so the error byte is not
|
||||
// misinterpreted as part of the nonce by a re-trying client.
|
||||
if (lastAuthFailure != 0 && (millis() - lastAuthFailure) < OTA_AUTH_COOLDOWN_MS) {
|
||||
LOG_WARN("ETH OTA: Auth cooldown active, rejecting connection");
|
||||
client.stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate random nonce
|
||||
uint8_t nonce[OTA_NONCE_SIZE];
|
||||
for (size_t i = 0; i < OTA_NONCE_SIZE; i += 4) {
|
||||
uint32_t r = random();
|
||||
size_t remaining = OTA_NONCE_SIZE - i;
|
||||
memcpy(nonce + i, &r, min((size_t)4, remaining));
|
||||
}
|
||||
|
||||
// Send nonce to client
|
||||
client.write(nonce, OTA_NONCE_SIZE);
|
||||
|
||||
// Read client's response: SHA256(nonce || PSK)
|
||||
uint8_t clientHash[OTA_HASH_SIZE];
|
||||
if (!readExact(client, clientHash, OTA_HASH_SIZE)) {
|
||||
LOG_WARN("ETH OTA: Timeout reading auth response");
|
||||
lastAuthFailure = millis();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compute expected hash
|
||||
uint8_t expectedHash[OTA_HASH_SIZE];
|
||||
computeAuthHash(nonce, OTA_NONCE_SIZE, otaPSK, otaPSKSize, expectedHash);
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
uint8_t diff = 0;
|
||||
for (size_t i = 0; i < OTA_HASH_SIZE; i++) {
|
||||
diff |= clientHash[i] ^ expectedHash[i];
|
||||
}
|
||||
|
||||
if (diff != 0) {
|
||||
LOG_WARN("ETH OTA: Authentication failed");
|
||||
client.write(OTA_ERR_AUTH);
|
||||
lastAuthFailure = millis();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auth success — send ACK
|
||||
client.write(OTA_ACK);
|
||||
LOG_INFO("ETH OTA: Authentication successful");
|
||||
return true;
|
||||
}
|
||||
|
||||
static void handleOTAClient(EthernetClient &client)
|
||||
{
|
||||
LOG_INFO("ETH OTA: Client connected from %u.%u.%u.%u", client.remoteIP()[0], client.remoteIP()[1], client.remoteIP()[2],
|
||||
client.remoteIP()[3]);
|
||||
|
||||
// Step 1: Challenge-response authentication
|
||||
if (!authenticateClient(client)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Read 12-byte header
|
||||
OTAHeader hdr;
|
||||
if (!readExact(client, (uint8_t *)&hdr, sizeof(hdr))) {
|
||||
LOG_WARN("ETH OTA: Timeout reading header");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate magic
|
||||
if (memcmp(hdr.magic, "MOTA", 4) != 0) {
|
||||
LOG_WARN("ETH OTA: Invalid magic");
|
||||
client.write(OTA_ERR_MAGIC);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("ETH OTA: Firmware size=%u, CRC32=0x%08X", hdr.firmwareSize, hdr.crc32);
|
||||
|
||||
// Sanity check on size (must be > 0 and fit in LittleFS)
|
||||
if (hdr.firmwareSize == 0 || hdr.firmwareSize > 1024 * 1024) {
|
||||
LOG_WARN("ETH OTA: Invalid firmware size");
|
||||
client.write(OTA_ERR_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Begin the update — this opens firmware.bin on LittleFS
|
||||
if (!Update.begin(hdr.firmwareSize)) {
|
||||
LOG_ERROR("ETH OTA: Update.begin() failed, error=%u", Update.getError());
|
||||
client.write(OTA_ERR_BEGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACK the header — client can start sending firmware data
|
||||
client.write(OTA_ACK);
|
||||
|
||||
// Receive firmware in chunks
|
||||
uint8_t buf[OTA_CHUNK_SIZE];
|
||||
size_t remaining = hdr.firmwareSize;
|
||||
uint32_t crc = CRC32_INITIAL;
|
||||
uint32_t lastActivity = millis();
|
||||
size_t totalReceived = 0;
|
||||
|
||||
while (remaining > 0) {
|
||||
if (!client.connected()) {
|
||||
LOG_WARN("ETH OTA: Client disconnected during transfer");
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
|
||||
int avail = client.available();
|
||||
if (avail <= 0) {
|
||||
if (millis() - lastActivity > OTA_TIMEOUT_MS) {
|
||||
LOG_WARN("ETH OTA: Timeout during transfer (%u/%u bytes)", totalReceived, hdr.firmwareSize);
|
||||
client.write(OTA_ERR_TIMEOUT);
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
delay(1);
|
||||
FEED_WATCHDOG();
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t toRead = min((size_t)avail, min(remaining, sizeof(buf)));
|
||||
size_t got = client.read(buf, toRead);
|
||||
if (got == 0)
|
||||
continue;
|
||||
|
||||
// Write to Updater (LittleFS firmware.bin)
|
||||
size_t written = Update.write(buf, got);
|
||||
if (written != got) {
|
||||
LOG_ERROR("ETH OTA: Write failed (wrote %u of %u), error=%u", written, got, Update.getError());
|
||||
client.write(OTA_ERR_WRITE);
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
|
||||
crc = crc32Update(buf, got, crc);
|
||||
remaining -= got;
|
||||
totalReceived += got;
|
||||
lastActivity = millis();
|
||||
FEED_WATCHDOG();
|
||||
|
||||
// Progress log every ~10%
|
||||
if (totalReceived % (hdr.firmwareSize / 10 + 1) < got) {
|
||||
LOG_INFO("ETH OTA: %u%% (%u/%u bytes)", (uint32_t)(100ULL * totalReceived / hdr.firmwareSize), totalReceived,
|
||||
hdr.firmwareSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify CRC32
|
||||
uint32_t computedCRC = crc32Final(crc);
|
||||
if (computedCRC != hdr.crc32) {
|
||||
LOG_ERROR("ETH OTA: CRC mismatch (expected=0x%08X, computed=0x%08X)", hdr.crc32, computedCRC);
|
||||
client.write(OTA_ERR_CRC);
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize — this calls picoOTA.commit() which stages the update for the
|
||||
// bootloader
|
||||
if (!Update.end(true)) {
|
||||
LOG_ERROR("ETH OTA: Update.end() failed, error=%u", Update.getError());
|
||||
client.write(OTA_ERR_WRITE);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("ETH OTA: Update staged successfully (%u bytes). Rebooting...", hdr.firmwareSize);
|
||||
client.write(OTA_OK);
|
||||
client.flush();
|
||||
delay(500);
|
||||
|
||||
// Reboot — the built-in bootloader will apply the update from LittleFS
|
||||
rp2040.reboot();
|
||||
}
|
||||
|
||||
void initEthOTA()
|
||||
{
|
||||
if (!otaServer) {
|
||||
otaServer = new EthernetServer(ETH_OTA_PORT);
|
||||
otaServer->begin();
|
||||
LOG_INFO("ETH OTA: Server listening on TCP port %d", ETH_OTA_PORT);
|
||||
}
|
||||
}
|
||||
|
||||
void ethOTALoop()
|
||||
{
|
||||
if (!otaServer)
|
||||
return;
|
||||
|
||||
EthernetClient client = otaServer->accept();
|
||||
if (client) {
|
||||
handleOTAClient(client);
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // HAS_ETHERNET && HAS_ETHERNET_OTA
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
|
||||
#ifdef USE_ARDUINO_ETHERNET
|
||||
#include <Ethernet.h>
|
||||
#else
|
||||
#include <RAK13800_W5100S.h>
|
||||
#endif
|
||||
|
||||
#define ETH_OTA_PORT 4243
|
||||
|
||||
/// Initialize the Ethernet OTA server (call after Ethernet is connected)
|
||||
void initEthOTA();
|
||||
|
||||
/// Poll for incoming OTA connections (call periodically from ethClient
|
||||
/// reconnect loop)
|
||||
void ethOTALoop();
|
||||
|
||||
#endif // HAS_ETHERNET && HAS_ETHERNET_OTA
|
||||
@@ -198,6 +198,28 @@ typedef struct _meshtastic_LockdownAuth {
|
||||
way to reset the session clock is a reboot, which costs a boot
|
||||
from the on-flash, HMAC-bound counter. */
|
||||
uint32_t max_session_seconds;
|
||||
/* Disable lockdown mode. Requires a valid passphrase in the same
|
||||
message (the device must prove the operator owns it before
|
||||
reverting at-rest encryption). On success the firmware decrypts
|
||||
every stored config / channel / nodedb file back to plaintext,
|
||||
removes the wrapped DEK, unlock token, monotonic-counter, and
|
||||
backoff files, and reboots out of lockdown.
|
||||
|
||||
This is the inverse of the provision/unlock path: it is how the
|
||||
client app's "lockdown mode" toggle returns a device to normal
|
||||
operation.
|
||||
|
||||
NOT reversed by this operation: APPROTECT. Once the debug port
|
||||
lockout has been burned (on silicon where it is effective) it is
|
||||
permanent — disabling lockdown decrypts your data and removes the
|
||||
access gates, but the SWD/JTAG port stays locked for the life of
|
||||
the device (recoverable only via a full chip erase over a debug
|
||||
probe, which destroys all data). Clients should make this
|
||||
irreversibility clear at the moment lockdown is first enabled.
|
||||
|
||||
When true the passphrase field is still required; boots_remaining,
|
||||
valid_until_epoch, max_session_seconds, and lock_now are ignored. */
|
||||
bool disable;
|
||||
} meshtastic_LockdownAuth;
|
||||
|
||||
/* Parameters for setting up Meshtastic for ameteur radio usage */
|
||||
@@ -212,6 +234,9 @@ typedef struct _meshtastic_HamParameters {
|
||||
float frequency;
|
||||
/* Optional short name of user */
|
||||
char short_name[5];
|
||||
/* Optional long name of user
|
||||
Appended to callsign */
|
||||
char long_name[15];
|
||||
} meshtastic_HamParameters;
|
||||
|
||||
/* Response envelope for node_remote_hardware_pins */
|
||||
@@ -521,8 +546,8 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}}
|
||||
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, ""}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, "", ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
|
||||
#define meshtastic_KeyVerificationAdmin_init_default {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
|
||||
@@ -534,8 +559,8 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}}
|
||||
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, ""}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, "", ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
|
||||
#define meshtastic_KeyVerificationAdmin_init_zero {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
|
||||
@@ -557,10 +582,12 @@ extern "C" {
|
||||
#define meshtastic_LockdownAuth_valid_until_epoch_tag 3
|
||||
#define meshtastic_LockdownAuth_lock_now_tag 4
|
||||
#define meshtastic_LockdownAuth_max_session_seconds_tag 5
|
||||
#define meshtastic_LockdownAuth_disable_tag 6
|
||||
#define meshtastic_HamParameters_call_sign_tag 1
|
||||
#define meshtastic_HamParameters_tx_power_tag 2
|
||||
#define meshtastic_HamParameters_frequency_tag 3
|
||||
#define meshtastic_HamParameters_short_name_tag 4
|
||||
#define meshtastic_HamParameters_long_name_tag 5
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_node_remote_hardware_pins_tag 1
|
||||
#define meshtastic_SharedContact_node_num_tag 1
|
||||
#define meshtastic_SharedContact_user_tag 2
|
||||
@@ -754,7 +781,8 @@ X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \
|
||||
X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \
|
||||
X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \
|
||||
X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \
|
||||
X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5)
|
||||
X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) \
|
||||
X(a, STATIC, SINGULAR, BOOL, disable, 6)
|
||||
#define meshtastic_LockdownAuth_CALLBACK NULL
|
||||
#define meshtastic_LockdownAuth_DEFAULT NULL
|
||||
|
||||
@@ -762,7 +790,8 @@ X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5)
|
||||
X(a, STATIC, SINGULAR, STRING, call_sign, 1) \
|
||||
X(a, STATIC, SINGULAR, INT32, tx_power, 2) \
|
||||
X(a, STATIC, SINGULAR, FLOAT, frequency, 3) \
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 4)
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 4) \
|
||||
X(a, STATIC, SINGULAR, STRING, long_name, 5)
|
||||
#define meshtastic_HamParameters_CALLBACK NULL
|
||||
#define meshtastic_HamParameters_DEFAULT NULL
|
||||
|
||||
@@ -867,9 +896,9 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
|
||||
#define meshtastic_AdminMessage_InputEvent_size 14
|
||||
#define meshtastic_AdminMessage_OTAEvent_size 36
|
||||
#define meshtastic_AdminMessage_size 511
|
||||
#define meshtastic_HamParameters_size 31
|
||||
#define meshtastic_HamParameters_size 47
|
||||
#define meshtastic_KeyVerificationAdmin_size 25
|
||||
#define meshtastic_LockdownAuth_size 54
|
||||
#define meshtastic_LockdownAuth_size 56
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_size 496
|
||||
#define meshtastic_SCD30_config_size 27
|
||||
#define meshtastic_SCD4X_config_size 29
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
typedef struct _meshtastic_DeviceProfile {
|
||||
/* Long name for the node */
|
||||
bool has_long_name;
|
||||
char long_name[40];
|
||||
char long_name[25];
|
||||
/* Short name of the node */
|
||||
bool has_short_name;
|
||||
char short_name[5];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user