Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abd11ad0bc | ||
|
|
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 | ||
|
|
3feb155a5f | ||
|
|
64e35908c9 | ||
|
|
8a1d6d9285 | ||
|
|
ee441dd7b2 | ||
|
|
178ae0a7f1 | ||
|
|
a08872299e | ||
|
|
cc9d433db0 | ||
|
|
60303968bb | ||
|
|
d3c7f05baa | ||
|
|
1971e5ab13 | ||
|
|
e2fda6598c | ||
|
|
982440d21d | ||
|
|
f9fea562aa | ||
|
|
c366296ab4 | ||
|
|
a076d97eb6 | ||
|
|
32dcd90abf | ||
|
|
5b7a5b2c22 | ||
|
|
5bce26d9b7 | ||
|
|
ef734b73c7 | ||
|
|
c7748a1602 | ||
|
|
91f930d5c0 | ||
|
|
f2c5cb0a05 | ||
|
|
e10e13226d | ||
|
|
5e69bc6c3f | ||
|
|
f3cb2bff78 | ||
|
|
894c5556cf | ||
|
|
2f92eb8499 |
@@ -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
|
||||
|
||||
@@ -283,6 +283,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 +618,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'.
|
||||
@@ -333,6 +413,7 @@ jobs:
|
||||
prerelease: true
|
||||
name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
|
||||
tag_name: v${{ needs.version.outputs.long }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
body: ${{ steps.release_notes.outputs.notes }}
|
||||
|
||||
- name: Download source deb
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/* \
|
||||
|
||||
+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]
|
||||
@@ -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"],
|
||||
["0x2886", "0x1667"]
|
||||
],
|
||||
"usb_product": "HT-n5262",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "heltec_mesh_node_t1",
|
||||
"variants_dir": "variants",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": ["jlink"],
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Heltec Mesh Node T1",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://heltec.org",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -1,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)
|
||||
@@ -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)
|
||||
|
||||
+5
-6
@@ -186,16 +186,21 @@ lib_deps =
|
||||
https://github.com/sparkfun/SparkFun_MAX3010x_Sensor_Library/archive/refs/tags/v1.1.2.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun 9DoF IMU Breakout ICM 20948 packageName=sparkfun/SparkFun_ICM-20948_ArduinoLibrary
|
||||
https://github.com/sparkfun/SparkFun_ICM-20948_ArduinoLibrary/archive/refs/tags/v1.3.2.zip
|
||||
# renovate: datasource=github-tags depName=TDK InvenSense ICM42670P packageName=tdk-invn-oss/motion.arduino.ICM42670P
|
||||
https://github.com/tdk-invn-oss/motion.arduino.ICM42670P/archive/refs/tags/1.0.8.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LTR390 Library packageName=adafruit/Adafruit_LTR390
|
||||
https://github.com/adafruit/Adafruit_LTR390/archive/refs/tags/1.1.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit PCT2075 packageName=adafruit/Adafruit_PCT2075
|
||||
https://github.com/adafruit/Adafruit_PCT2075/archive/refs/tags/1.0.6.zip
|
||||
# renovate: datasource=github-tags depName=DFRobot_BMM150 packageName=dfrobot/DFRobot_BMM150
|
||||
https://github.com/DFRobot/DFRobot_BMM150/archive/refs/tags/V1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun MMC5983MA Magnetometer packageName=sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library/archive/refs/tags/v1.1.4.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit_TSL2561 packageName=adafruit/Adafruit_TSL2561
|
||||
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
|
||||
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
|
||||
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
|
||||
https://github.com/xioTechnologies/Fusion/archive/a93c0dc83ce3ab65246f63ba134d3c2a15d6cabf.zip
|
||||
|
||||
; Common environmental sensor libraries (not included in native / portduino)
|
||||
[environmental_extra_common]
|
||||
@@ -204,16 +209,10 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
|
||||
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHTC3 packageName=adafruit/Adafruit_SHTC3
|
||||
https://github.com/adafruit/Adafruit_SHTC3/archive/refs/tags/1.0.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
|
||||
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT31 packageName=adafruit/Adafruit_SHT31
|
||||
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
|
||||
|
||||
+1
-1
Submodule protobufs updated: e978a1850b...1df6c11542
@@ -152,7 +152,9 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
|
||||
// Default Bluetooth PIN
|
||||
#define defaultBLEPin 123456
|
||||
|
||||
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||
#if HAS_ETHERNET && defined(USE_ARDUINO_ETHERNET)
|
||||
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5500 auto-detect
|
||||
#elif HAS_ETHERNET && defined(USE_CH390D)
|
||||
#include <ESP32_CH390.h>
|
||||
#elif HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#include <RAK13800_W5100S.h>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "DisplayFormatters.h"
|
||||
#include "MeshRadio.h"
|
||||
|
||||
const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName,
|
||||
bool usePreset)
|
||||
@@ -11,33 +12,51 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
||||
}
|
||||
|
||||
switch (preset) {
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
|
||||
case PRESET(SHORT_TURBO):
|
||||
return useShortName ? "ShortT" : "ShortTurbo";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
|
||||
case PRESET(SHORT_SLOW):
|
||||
return useShortName ? "ShortS" : "ShortSlow";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
|
||||
case PRESET(SHORT_FAST):
|
||||
return useShortName ? "ShortF" : "ShortFast";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
|
||||
case PRESET(MEDIUM_SLOW):
|
||||
return useShortName ? "MedS" : "MediumSlow";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
|
||||
case PRESET(MEDIUM_FAST):
|
||||
return useShortName ? "MedF" : "MediumFast";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
|
||||
case PRESET(LONG_SLOW):
|
||||
return useShortName ? "LongS" : "LongSlow";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
|
||||
case PRESET(LONG_FAST):
|
||||
return useShortName ? "LongF" : "LongFast";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO:
|
||||
case PRESET(LONG_TURBO):
|
||||
return useShortName ? "LongT" : "LongTurbo";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
|
||||
case PRESET(LONG_MODERATE):
|
||||
return useShortName ? "LongM" : "LongMod";
|
||||
break;
|
||||
case PRESET(LITE_FAST):
|
||||
return useShortName ? "LiteF" : "LiteFast";
|
||||
break;
|
||||
case PRESET(LITE_SLOW):
|
||||
return useShortName ? "LiteS" : "LiteSlow";
|
||||
break;
|
||||
case PRESET(NARROW_FAST):
|
||||
return useShortName ? "NarF" : "NarrowFast";
|
||||
break;
|
||||
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()
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @file Fusion.h
|
||||
* @author Seb Madgwick
|
||||
* @brief Main header file for the Fusion library. This is the only file that
|
||||
* needs to be included when using the library.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_H
|
||||
#define FUSION_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "FusionAhrs.h"
|
||||
#include "FusionAxes.h"
|
||||
#include "FusionCalibration.h"
|
||||
#include "FusionCompass.h"
|
||||
#include "FusionConvention.h"
|
||||
#include "FusionMath.h"
|
||||
#include "FusionOffset.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,542 +0,0 @@
|
||||
/**
|
||||
* @file FusionAhrs.c
|
||||
* @author Seb Madgwick
|
||||
* @brief AHRS algorithm to combine gyroscope, accelerometer, and magnetometer
|
||||
* measurements into a single measurement of orientation relative to the Earth.
|
||||
*/
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionAhrs.h"
|
||||
#include <float.h> // FLT_MAX
|
||||
#include <math.h> // atan2f, cosf, fabsf, powf, sinf
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Definitions
|
||||
|
||||
/**
|
||||
* @brief Initial gain used during the initialisation.
|
||||
*/
|
||||
#define INITIAL_GAIN (10.0f)
|
||||
|
||||
/**
|
||||
* @brief Initialisation period in seconds.
|
||||
*/
|
||||
#define INITIALISATION_PERIOD (3.0f)
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Function declarations
|
||||
|
||||
static inline FusionVector HalfGravity(const FusionAhrs *const ahrs);
|
||||
|
||||
static inline FusionVector HalfMagnetic(const FusionAhrs *const ahrs);
|
||||
|
||||
static inline FusionVector Feedback(const FusionVector sensor, const FusionVector reference);
|
||||
|
||||
static inline int Clamp(const int value, const int min, const int max);
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Functions
|
||||
|
||||
/**
|
||||
* @brief Initialises the AHRS algorithm structure.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
*/
|
||||
void FusionAhrsInitialise(FusionAhrs *const ahrs)
|
||||
{
|
||||
const FusionAhrsSettings settings = {
|
||||
.convention = FusionConventionNwu,
|
||||
.gain = 0.5f,
|
||||
.gyroscopeRange = 0.0f,
|
||||
.accelerationRejection = 90.0f,
|
||||
.magneticRejection = 90.0f,
|
||||
.recoveryTriggerPeriod = 0,
|
||||
};
|
||||
FusionAhrsSetSettings(ahrs, &settings);
|
||||
FusionAhrsReset(ahrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Resets the AHRS algorithm. This is equivalent to reinitialising the
|
||||
* algorithm while maintaining the current settings.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
*/
|
||||
void FusionAhrsReset(FusionAhrs *const ahrs)
|
||||
{
|
||||
ahrs->quaternion = FUSION_IDENTITY_QUATERNION;
|
||||
ahrs->accelerometer = FUSION_VECTOR_ZERO;
|
||||
ahrs->initialising = true;
|
||||
ahrs->rampedGain = INITIAL_GAIN;
|
||||
ahrs->angularRateRecovery = false;
|
||||
ahrs->halfAccelerometerFeedback = FUSION_VECTOR_ZERO;
|
||||
ahrs->halfMagnetometerFeedback = FUSION_VECTOR_ZERO;
|
||||
ahrs->accelerometerIgnored = false;
|
||||
ahrs->accelerationRecoveryTrigger = 0;
|
||||
ahrs->accelerationRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
|
||||
ahrs->magnetometerIgnored = false;
|
||||
ahrs->magneticRecoveryTrigger = 0;
|
||||
ahrs->magneticRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sets the AHRS algorithm settings.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @param settings Settings.
|
||||
*/
|
||||
void FusionAhrsSetSettings(FusionAhrs *const ahrs, const FusionAhrsSettings *const settings)
|
||||
{
|
||||
ahrs->settings.convention = settings->convention;
|
||||
ahrs->settings.gain = settings->gain;
|
||||
ahrs->settings.gyroscopeRange = settings->gyroscopeRange == 0.0f ? FLT_MAX : 0.98f * settings->gyroscopeRange;
|
||||
ahrs->settings.accelerationRejection = settings->accelerationRejection == 0.0f
|
||||
? FLT_MAX
|
||||
: powf(0.5f * sinf(FusionDegreesToRadians(settings->accelerationRejection)), 2);
|
||||
ahrs->settings.magneticRejection =
|
||||
settings->magneticRejection == 0.0f ? FLT_MAX : powf(0.5f * sinf(FusionDegreesToRadians(settings->magneticRejection)), 2);
|
||||
ahrs->settings.recoveryTriggerPeriod = settings->recoveryTriggerPeriod;
|
||||
ahrs->accelerationRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
|
||||
ahrs->magneticRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
|
||||
if ((settings->gain == 0.0f) ||
|
||||
(settings->recoveryTriggerPeriod == 0)) { // disable acceleration and magnetic rejection features if gain is zero
|
||||
ahrs->settings.accelerationRejection = FLT_MAX;
|
||||
ahrs->settings.magneticRejection = FLT_MAX;
|
||||
}
|
||||
if (ahrs->initialising == false) {
|
||||
ahrs->rampedGain = ahrs->settings.gain;
|
||||
}
|
||||
ahrs->rampedGainStep = (INITIAL_GAIN - ahrs->settings.gain) / INITIALISATION_PERIOD;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Updates the AHRS algorithm using the gyroscope, accelerometer, and
|
||||
* magnetometer measurements.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @param gyroscope Gyroscope measurement in degrees per second.
|
||||
* @param accelerometer Accelerometer measurement in g.
|
||||
* @param magnetometer Magnetometer measurement in arbitrary units.
|
||||
* @param deltaTime Delta time in seconds.
|
||||
*/
|
||||
void FusionAhrsUpdate(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
|
||||
const FusionVector magnetometer, const float deltaTime)
|
||||
{
|
||||
#define Q ahrs->quaternion.element
|
||||
|
||||
// Store accelerometer
|
||||
ahrs->accelerometer = accelerometer;
|
||||
|
||||
// Reinitialise if gyroscope range exceeded
|
||||
if ((fabsf(gyroscope.axis.x) > ahrs->settings.gyroscopeRange) || (fabsf(gyroscope.axis.y) > ahrs->settings.gyroscopeRange) ||
|
||||
(fabsf(gyroscope.axis.z) > ahrs->settings.gyroscopeRange)) {
|
||||
const FusionQuaternion quaternion = ahrs->quaternion;
|
||||
FusionAhrsReset(ahrs);
|
||||
ahrs->quaternion = quaternion;
|
||||
ahrs->angularRateRecovery = true;
|
||||
}
|
||||
|
||||
// Ramp down gain during initialisation
|
||||
if (ahrs->initialising) {
|
||||
ahrs->rampedGain -= ahrs->rampedGainStep * deltaTime;
|
||||
if ((ahrs->rampedGain < ahrs->settings.gain) || (ahrs->settings.gain == 0.0f)) {
|
||||
ahrs->rampedGain = ahrs->settings.gain;
|
||||
ahrs->initialising = false;
|
||||
ahrs->angularRateRecovery = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate direction of gravity indicated by algorithm
|
||||
const FusionVector halfGravity = HalfGravity(ahrs);
|
||||
|
||||
// Calculate accelerometer feedback
|
||||
FusionVector halfAccelerometerFeedback = FUSION_VECTOR_ZERO;
|
||||
ahrs->accelerometerIgnored = true;
|
||||
if (FusionVectorIsZero(accelerometer) == false) {
|
||||
|
||||
// Calculate accelerometer feedback scaled by 0.5
|
||||
ahrs->halfAccelerometerFeedback = Feedback(FusionVectorNormalise(accelerometer), halfGravity);
|
||||
|
||||
// Don't ignore accelerometer if acceleration error below threshold
|
||||
if (ahrs->initialising ||
|
||||
((FusionVectorMagnitudeSquared(ahrs->halfAccelerometerFeedback) <= ahrs->settings.accelerationRejection))) {
|
||||
ahrs->accelerometerIgnored = false;
|
||||
ahrs->accelerationRecoveryTrigger -= 9;
|
||||
} else {
|
||||
ahrs->accelerationRecoveryTrigger += 1;
|
||||
}
|
||||
|
||||
// Don't ignore accelerometer during acceleration recovery
|
||||
if (ahrs->accelerationRecoveryTrigger > ahrs->accelerationRecoveryTimeout) {
|
||||
ahrs->accelerationRecoveryTimeout = 0;
|
||||
ahrs->accelerometerIgnored = false;
|
||||
} else {
|
||||
ahrs->accelerationRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
|
||||
}
|
||||
ahrs->accelerationRecoveryTrigger = Clamp(ahrs->accelerationRecoveryTrigger, 0, ahrs->settings.recoveryTriggerPeriod);
|
||||
|
||||
// Apply accelerometer feedback
|
||||
if (ahrs->accelerometerIgnored == false) {
|
||||
halfAccelerometerFeedback = ahrs->halfAccelerometerFeedback;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate magnetometer feedback
|
||||
FusionVector halfMagnetometerFeedback = FUSION_VECTOR_ZERO;
|
||||
ahrs->magnetometerIgnored = true;
|
||||
if (FusionVectorIsZero(magnetometer) == false) {
|
||||
|
||||
// Calculate direction of magnetic field indicated by algorithm
|
||||
const FusionVector halfMagnetic = HalfMagnetic(ahrs);
|
||||
|
||||
// Calculate magnetometer feedback scaled by 0.5
|
||||
ahrs->halfMagnetometerFeedback =
|
||||
Feedback(FusionVectorNormalise(FusionVectorCrossProduct(halfGravity, magnetometer)), halfMagnetic);
|
||||
|
||||
// Don't ignore magnetometer if magnetic error below threshold
|
||||
if (ahrs->initialising ||
|
||||
((FusionVectorMagnitudeSquared(ahrs->halfMagnetometerFeedback) <= ahrs->settings.magneticRejection))) {
|
||||
ahrs->magnetometerIgnored = false;
|
||||
ahrs->magneticRecoveryTrigger -= 9;
|
||||
} else {
|
||||
ahrs->magneticRecoveryTrigger += 1;
|
||||
}
|
||||
|
||||
// Don't ignore magnetometer during magnetic recovery
|
||||
if (ahrs->magneticRecoveryTrigger > ahrs->magneticRecoveryTimeout) {
|
||||
ahrs->magneticRecoveryTimeout = 0;
|
||||
ahrs->magnetometerIgnored = false;
|
||||
} else {
|
||||
ahrs->magneticRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
|
||||
}
|
||||
ahrs->magneticRecoveryTrigger = Clamp(ahrs->magneticRecoveryTrigger, 0, ahrs->settings.recoveryTriggerPeriod);
|
||||
|
||||
// Apply magnetometer feedback
|
||||
if (ahrs->magnetometerIgnored == false) {
|
||||
halfMagnetometerFeedback = ahrs->halfMagnetometerFeedback;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert gyroscope to radians per second scaled by 0.5
|
||||
const FusionVector halfGyroscope = FusionVectorMultiplyScalar(gyroscope, FusionDegreesToRadians(0.5f));
|
||||
|
||||
// Apply feedback to gyroscope
|
||||
const FusionVector adjustedHalfGyroscope = FusionVectorAdd(
|
||||
halfGyroscope,
|
||||
FusionVectorMultiplyScalar(FusionVectorAdd(halfAccelerometerFeedback, halfMagnetometerFeedback), ahrs->rampedGain));
|
||||
|
||||
// Integrate rate of change of quaternion
|
||||
ahrs->quaternion = FusionQuaternionAdd(
|
||||
ahrs->quaternion,
|
||||
FusionQuaternionMultiplyVector(ahrs->quaternion, FusionVectorMultiplyScalar(adjustedHalfGyroscope, deltaTime)));
|
||||
|
||||
// Normalise quaternion
|
||||
ahrs->quaternion = FusionQuaternionNormalise(ahrs->quaternion);
|
||||
#undef Q
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the direction of gravity scaled by 0.5.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @return Direction of gravity scaled by 0.5.
|
||||
*/
|
||||
static inline FusionVector HalfGravity(const FusionAhrs *const ahrs)
|
||||
{
|
||||
#define Q ahrs->quaternion.element
|
||||
switch (ahrs->settings.convention) {
|
||||
case FusionConventionNwu:
|
||||
case FusionConventionEnu: {
|
||||
const FusionVector halfGravity = {.axis = {
|
||||
.x = Q.x * Q.z - Q.w * Q.y,
|
||||
.y = Q.y * Q.z + Q.w * Q.x,
|
||||
.z = Q.w * Q.w - 0.5f + Q.z * Q.z,
|
||||
}}; // third column of transposed rotation matrix scaled by 0.5
|
||||
return halfGravity;
|
||||
}
|
||||
case FusionConventionNed: {
|
||||
const FusionVector halfGravity = {.axis = {
|
||||
.x = Q.w * Q.y - Q.x * Q.z,
|
||||
.y = -1.0f * (Q.y * Q.z + Q.w * Q.x),
|
||||
.z = 0.5f - Q.w * Q.w - Q.z * Q.z,
|
||||
}}; // third column of transposed rotation matrix scaled by -0.5
|
||||
return halfGravity;
|
||||
}
|
||||
}
|
||||
return FUSION_VECTOR_ZERO; // avoid compiler warning
|
||||
#undef Q
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the direction of the magnetic field scaled by 0.5.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @return Direction of the magnetic field scaled by 0.5.
|
||||
*/
|
||||
static inline FusionVector HalfMagnetic(const FusionAhrs *const ahrs)
|
||||
{
|
||||
#define Q ahrs->quaternion.element
|
||||
switch (ahrs->settings.convention) {
|
||||
case FusionConventionNwu: {
|
||||
const FusionVector halfMagnetic = {.axis = {
|
||||
.x = Q.x * Q.y + Q.w * Q.z,
|
||||
.y = Q.w * Q.w - 0.5f + Q.y * Q.y,
|
||||
.z = Q.y * Q.z - Q.w * Q.x,
|
||||
}}; // second column of transposed rotation matrix scaled by 0.5
|
||||
return halfMagnetic;
|
||||
}
|
||||
case FusionConventionEnu: {
|
||||
const FusionVector halfMagnetic = {.axis = {
|
||||
.x = 0.5f - Q.w * Q.w - Q.x * Q.x,
|
||||
.y = Q.w * Q.z - Q.x * Q.y,
|
||||
.z = -1.0f * (Q.x * Q.z + Q.w * Q.y),
|
||||
}}; // first column of transposed rotation matrix scaled by -0.5
|
||||
return halfMagnetic;
|
||||
}
|
||||
case FusionConventionNed: {
|
||||
const FusionVector halfMagnetic = {.axis = {
|
||||
.x = -1.0f * (Q.x * Q.y + Q.w * Q.z),
|
||||
.y = 0.5f - Q.w * Q.w - Q.y * Q.y,
|
||||
.z = Q.w * Q.x - Q.y * Q.z,
|
||||
}}; // second column of transposed rotation matrix scaled by -0.5
|
||||
return halfMagnetic;
|
||||
}
|
||||
}
|
||||
return FUSION_VECTOR_ZERO; // avoid compiler warning
|
||||
#undef Q
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the feedback.
|
||||
* @param sensor Sensor.
|
||||
* @param reference Reference.
|
||||
* @return Feedback.
|
||||
*/
|
||||
static inline FusionVector Feedback(const FusionVector sensor, const FusionVector reference)
|
||||
{
|
||||
if (FusionVectorDotProduct(sensor, reference) < 0.0f) { // if error is >90 degrees
|
||||
return FusionVectorNormalise(FusionVectorCrossProduct(sensor, reference));
|
||||
}
|
||||
return FusionVectorCrossProduct(sensor, reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns a value limited to maximum and minimum.
|
||||
* @param value Value.
|
||||
* @param min Minimum value.
|
||||
* @param max Maximum value.
|
||||
* @return Value limited to maximum and minimum.
|
||||
*/
|
||||
static inline int Clamp(const int value, const int min, const int max)
|
||||
{
|
||||
if (value < min) {
|
||||
return min;
|
||||
}
|
||||
if (value > max) {
|
||||
return max;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Updates the AHRS algorithm using the gyroscope and accelerometer
|
||||
* measurements only.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @param gyroscope Gyroscope measurement in degrees per second.
|
||||
* @param accelerometer Accelerometer measurement in g.
|
||||
* @param deltaTime Delta time in seconds.
|
||||
*/
|
||||
void FusionAhrsUpdateNoMagnetometer(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
|
||||
const float deltaTime)
|
||||
{
|
||||
|
||||
// Update AHRS algorithm
|
||||
FusionAhrsUpdate(ahrs, gyroscope, accelerometer, FUSION_VECTOR_ZERO, deltaTime);
|
||||
|
||||
// Zero heading during initialisation
|
||||
if (ahrs->initialising) {
|
||||
FusionAhrsSetHeading(ahrs, 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Updates the AHRS algorithm using the gyroscope, accelerometer, and
|
||||
* heading measurements.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @param gyroscope Gyroscope measurement in degrees per second.
|
||||
* @param accelerometer Accelerometer measurement in g.
|
||||
* @param heading Heading measurement in degrees.
|
||||
* @param deltaTime Delta time in seconds.
|
||||
*/
|
||||
void FusionAhrsUpdateExternalHeading(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
|
||||
const float heading, const float deltaTime)
|
||||
{
|
||||
#define Q ahrs->quaternion.element
|
||||
|
||||
// Calculate roll
|
||||
const float roll = atan2f(Q.w * Q.x + Q.y * Q.z, 0.5f - Q.y * Q.y - Q.x * Q.x);
|
||||
|
||||
// Calculate magnetometer
|
||||
const float headingRadians = FusionDegreesToRadians(heading);
|
||||
const float sinHeadingRadians = sinf(headingRadians);
|
||||
const FusionVector magnetometer = {.axis = {
|
||||
.x = cosf(headingRadians),
|
||||
.y = -1.0f * cosf(roll) * sinHeadingRadians,
|
||||
.z = sinHeadingRadians * sinf(roll),
|
||||
}};
|
||||
|
||||
// Update AHRS algorithm
|
||||
FusionAhrsUpdate(ahrs, gyroscope, accelerometer, magnetometer, deltaTime);
|
||||
#undef Q
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the quaternion describing the sensor relative to the Earth.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @return Quaternion describing the sensor relative to the Earth.
|
||||
*/
|
||||
FusionQuaternion FusionAhrsGetQuaternion(const FusionAhrs *const ahrs)
|
||||
{
|
||||
return ahrs->quaternion;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sets the quaternion describing the sensor relative to the Earth.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @param quaternion Quaternion describing the sensor relative to the Earth.
|
||||
*/
|
||||
void FusionAhrsSetQuaternion(FusionAhrs *const ahrs, const FusionQuaternion quaternion)
|
||||
{
|
||||
ahrs->quaternion = quaternion;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the linear acceleration measurement equal to the accelerometer
|
||||
* measurement with the 1 g of gravity removed.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @return Linear acceleration measurement in g.
|
||||
*/
|
||||
FusionVector FusionAhrsGetLinearAcceleration(const FusionAhrs *const ahrs)
|
||||
{
|
||||
#define Q ahrs->quaternion.element
|
||||
|
||||
// Calculate gravity in the sensor coordinate frame
|
||||
const FusionVector gravity = {.axis = {
|
||||
.x = 2.0f * (Q.x * Q.z - Q.w * Q.y),
|
||||
.y = 2.0f * (Q.y * Q.z + Q.w * Q.x),
|
||||
.z = 2.0f * (Q.w * Q.w - 0.5f + Q.z * Q.z),
|
||||
}}; // third column of transposed rotation matrix
|
||||
|
||||
// Remove gravity from accelerometer measurement
|
||||
switch (ahrs->settings.convention) {
|
||||
case FusionConventionNwu:
|
||||
case FusionConventionEnu: {
|
||||
return FusionVectorSubtract(ahrs->accelerometer, gravity);
|
||||
}
|
||||
case FusionConventionNed: {
|
||||
return FusionVectorAdd(ahrs->accelerometer, gravity);
|
||||
}
|
||||
}
|
||||
return FUSION_VECTOR_ZERO; // avoid compiler warning
|
||||
#undef Q
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the Earth acceleration measurement equal to accelerometer
|
||||
* measurement in the Earth coordinate frame with the 1 g of gravity removed.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @return Earth acceleration measurement in g.
|
||||
*/
|
||||
FusionVector FusionAhrsGetEarthAcceleration(const FusionAhrs *const ahrs)
|
||||
{
|
||||
#define Q ahrs->quaternion.element
|
||||
#define A ahrs->accelerometer.axis
|
||||
|
||||
// Calculate accelerometer measurement in the Earth coordinate frame
|
||||
const float qwqw = Q.w * Q.w; // calculate common terms to avoid repeated operations
|
||||
const float qwqx = Q.w * Q.x;
|
||||
const float qwqy = Q.w * Q.y;
|
||||
const float qwqz = Q.w * Q.z;
|
||||
const float qxqy = Q.x * Q.y;
|
||||
const float qxqz = Q.x * Q.z;
|
||||
const float qyqz = Q.y * Q.z;
|
||||
FusionVector accelerometer = {.axis = {
|
||||
.x = 2.0f * ((qwqw - 0.5f + Q.x * Q.x) * A.x + (qxqy - qwqz) * A.y + (qxqz + qwqy) * A.z),
|
||||
.y = 2.0f * ((qxqy + qwqz) * A.x + (qwqw - 0.5f + Q.y * Q.y) * A.y + (qyqz - qwqx) * A.z),
|
||||
.z = 2.0f * ((qxqz - qwqy) * A.x + (qyqz + qwqx) * A.y + (qwqw - 0.5f + Q.z * Q.z) * A.z),
|
||||
}}; // rotation matrix multiplied with the accelerometer
|
||||
|
||||
// Remove gravity from accelerometer measurement
|
||||
switch (ahrs->settings.convention) {
|
||||
case FusionConventionNwu:
|
||||
case FusionConventionEnu:
|
||||
accelerometer.axis.z -= 1.0f;
|
||||
break;
|
||||
case FusionConventionNed:
|
||||
accelerometer.axis.z += 1.0f;
|
||||
break;
|
||||
}
|
||||
return accelerometer;
|
||||
#undef Q
|
||||
#undef A
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the AHRS algorithm internal states.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @return AHRS algorithm internal states.
|
||||
*/
|
||||
FusionAhrsInternalStates FusionAhrsGetInternalStates(const FusionAhrs *const ahrs)
|
||||
{
|
||||
const FusionAhrsInternalStates internalStates = {
|
||||
.accelerationError = FusionRadiansToDegrees(FusionAsin(2.0f * FusionVectorMagnitude(ahrs->halfAccelerometerFeedback))),
|
||||
.accelerometerIgnored = ahrs->accelerometerIgnored,
|
||||
.accelerationRecoveryTrigger =
|
||||
ahrs->settings.recoveryTriggerPeriod == 0
|
||||
? 0.0f
|
||||
: (float)ahrs->accelerationRecoveryTrigger / (float)ahrs->settings.recoveryTriggerPeriod,
|
||||
.magneticError = FusionRadiansToDegrees(FusionAsin(2.0f * FusionVectorMagnitude(ahrs->halfMagnetometerFeedback))),
|
||||
.magnetometerIgnored = ahrs->magnetometerIgnored,
|
||||
.magneticRecoveryTrigger = ahrs->settings.recoveryTriggerPeriod == 0
|
||||
? 0.0f
|
||||
: (float)ahrs->magneticRecoveryTrigger / (float)ahrs->settings.recoveryTriggerPeriod,
|
||||
};
|
||||
return internalStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the AHRS algorithm flags.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @return AHRS algorithm flags.
|
||||
*/
|
||||
FusionAhrsFlags FusionAhrsGetFlags(const FusionAhrs *const ahrs)
|
||||
{
|
||||
const FusionAhrsFlags flags = {
|
||||
.initialising = ahrs->initialising,
|
||||
.angularRateRecovery = ahrs->angularRateRecovery,
|
||||
.accelerationRecovery = ahrs->accelerationRecoveryTrigger > ahrs->accelerationRecoveryTimeout,
|
||||
.magneticRecovery = ahrs->magneticRecoveryTrigger > ahrs->magneticRecoveryTimeout,
|
||||
};
|
||||
return flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sets the heading of the orientation measurement provided by the AHRS
|
||||
* algorithm. This function can be used to reset drift in heading when the AHRS
|
||||
* algorithm is being used without a magnetometer.
|
||||
* @param ahrs AHRS algorithm structure.
|
||||
* @param heading Heading angle in degrees.
|
||||
*/
|
||||
void FusionAhrsSetHeading(FusionAhrs *const ahrs, const float heading)
|
||||
{
|
||||
#define Q ahrs->quaternion.element
|
||||
const float yaw = atan2f(Q.w * Q.z + Q.x * Q.y, 0.5f - Q.y * Q.y - Q.z * Q.z);
|
||||
const float halfYawMinusHeading = 0.5f * (yaw - FusionDegreesToRadians(heading));
|
||||
const FusionQuaternion rotation = {.element = {
|
||||
.w = cosf(halfYawMinusHeading),
|
||||
.x = 0.0f,
|
||||
.y = 0.0f,
|
||||
.z = -1.0f * sinf(halfYawMinusHeading),
|
||||
}};
|
||||
ahrs->quaternion = FusionQuaternionMultiply(rotation, ahrs->quaternion);
|
||||
#undef Q
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* @file FusionAhrs.h
|
||||
* @author Seb Madgwick
|
||||
* @brief AHRS algorithm to combine gyroscope, accelerometer, and magnetometer
|
||||
* measurements into a single measurement of orientation relative to the Earth.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_AHRS_H
|
||||
#define FUSION_AHRS_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionConvention.h"
|
||||
#include "FusionMath.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Definitions
|
||||
|
||||
/**
|
||||
* @brief AHRS algorithm settings.
|
||||
*/
|
||||
typedef struct {
|
||||
FusionConvention convention;
|
||||
float gain;
|
||||
float gyroscopeRange;
|
||||
float accelerationRejection;
|
||||
float magneticRejection;
|
||||
unsigned int recoveryTriggerPeriod;
|
||||
} FusionAhrsSettings;
|
||||
|
||||
/**
|
||||
* @brief AHRS algorithm structure. Structure members are used internally and
|
||||
* must not be accessed by the application.
|
||||
*/
|
||||
typedef struct {
|
||||
FusionAhrsSettings settings;
|
||||
FusionQuaternion quaternion;
|
||||
FusionVector accelerometer;
|
||||
bool initialising;
|
||||
float rampedGain;
|
||||
float rampedGainStep;
|
||||
bool angularRateRecovery;
|
||||
FusionVector halfAccelerometerFeedback;
|
||||
FusionVector halfMagnetometerFeedback;
|
||||
bool accelerometerIgnored;
|
||||
int accelerationRecoveryTrigger;
|
||||
int accelerationRecoveryTimeout;
|
||||
bool magnetometerIgnored;
|
||||
int magneticRecoveryTrigger;
|
||||
int magneticRecoveryTimeout;
|
||||
} FusionAhrs;
|
||||
|
||||
/**
|
||||
* @brief AHRS algorithm internal states.
|
||||
*/
|
||||
typedef struct {
|
||||
float accelerationError;
|
||||
bool accelerometerIgnored;
|
||||
float accelerationRecoveryTrigger;
|
||||
float magneticError;
|
||||
bool magnetometerIgnored;
|
||||
float magneticRecoveryTrigger;
|
||||
} FusionAhrsInternalStates;
|
||||
|
||||
/**
|
||||
* @brief AHRS algorithm flags.
|
||||
*/
|
||||
typedef struct {
|
||||
bool initialising;
|
||||
bool angularRateRecovery;
|
||||
bool accelerationRecovery;
|
||||
bool magneticRecovery;
|
||||
} FusionAhrsFlags;
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Function declarations
|
||||
|
||||
void FusionAhrsInitialise(FusionAhrs *const ahrs);
|
||||
|
||||
void FusionAhrsReset(FusionAhrs *const ahrs);
|
||||
|
||||
void FusionAhrsSetSettings(FusionAhrs *const ahrs, const FusionAhrsSettings *const settings);
|
||||
|
||||
void FusionAhrsUpdate(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
|
||||
const FusionVector magnetometer, const float deltaTime);
|
||||
|
||||
void FusionAhrsUpdateNoMagnetometer(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
|
||||
const float deltaTime);
|
||||
|
||||
void FusionAhrsUpdateExternalHeading(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
|
||||
const float heading, const float deltaTime);
|
||||
|
||||
FusionQuaternion FusionAhrsGetQuaternion(const FusionAhrs *const ahrs);
|
||||
|
||||
void FusionAhrsSetQuaternion(FusionAhrs *const ahrs, const FusionQuaternion quaternion);
|
||||
|
||||
FusionVector FusionAhrsGetLinearAcceleration(const FusionAhrs *const ahrs);
|
||||
|
||||
FusionVector FusionAhrsGetEarthAcceleration(const FusionAhrs *const ahrs);
|
||||
|
||||
FusionAhrsInternalStates FusionAhrsGetInternalStates(const FusionAhrs *const ahrs);
|
||||
|
||||
FusionAhrsFlags FusionAhrsGetFlags(const FusionAhrs *const ahrs);
|
||||
|
||||
void FusionAhrsSetHeading(FusionAhrs *const ahrs, const float heading);
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* @file FusionAxes.h
|
||||
* @author Seb Madgwick
|
||||
* @brief Swaps sensor axes for alignment with the body axes.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_AXES_H
|
||||
#define FUSION_AXES_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionMath.h"
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Definitions
|
||||
|
||||
/**
|
||||
* @brief Axes alignment describing the sensor axes relative to the body axes.
|
||||
* For example, if the body X axis is aligned with the sensor Y axis and the
|
||||
* body Y axis is aligned with sensor X axis but pointing the opposite direction
|
||||
* then alignment is +Y-X+Z.
|
||||
*/
|
||||
typedef enum {
|
||||
FusionAxesAlignmentPXPYPZ, /* +X+Y+Z */
|
||||
FusionAxesAlignmentPXNZPY, /* +X-Z+Y */
|
||||
FusionAxesAlignmentPXNYNZ, /* +X-Y-Z */
|
||||
FusionAxesAlignmentPXPZNY, /* +X+Z-Y */
|
||||
FusionAxesAlignmentNXPYNZ, /* -X+Y-Z */
|
||||
FusionAxesAlignmentNXPZPY, /* -X+Z+Y */
|
||||
FusionAxesAlignmentNXNYPZ, /* -X-Y+Z */
|
||||
FusionAxesAlignmentNXNZNY, /* -X-Z-Y */
|
||||
FusionAxesAlignmentPYNXPZ, /* +Y-X+Z */
|
||||
FusionAxesAlignmentPYNZNX, /* +Y-Z-X */
|
||||
FusionAxesAlignmentPYPXNZ, /* +Y+X-Z */
|
||||
FusionAxesAlignmentPYPZPX, /* +Y+Z+X */
|
||||
FusionAxesAlignmentNYPXPZ, /* -Y+X+Z */
|
||||
FusionAxesAlignmentNYNZPX, /* -Y-Z+X */
|
||||
FusionAxesAlignmentNYNXNZ, /* -Y-X-Z */
|
||||
FusionAxesAlignmentNYPZNX, /* -Y+Z-X */
|
||||
FusionAxesAlignmentPZPYNX, /* +Z+Y-X */
|
||||
FusionAxesAlignmentPZPXPY, /* +Z+X+Y */
|
||||
FusionAxesAlignmentPZNYPX, /* +Z-Y+X */
|
||||
FusionAxesAlignmentPZNXNY, /* +Z-X-Y */
|
||||
FusionAxesAlignmentNZPYPX, /* -Z+Y+X */
|
||||
FusionAxesAlignmentNZNXPY, /* -Z-X+Y */
|
||||
FusionAxesAlignmentNZNYNX, /* -Z-Y-X */
|
||||
FusionAxesAlignmentNZPXNY, /* -Z+X-Y */
|
||||
} FusionAxesAlignment;
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions
|
||||
|
||||
/**
|
||||
* @brief Swaps sensor axes for alignment with the body axes.
|
||||
* @param sensor Sensor axes.
|
||||
* @param alignment Axes alignment.
|
||||
* @return Sensor axes aligned with the body axes.
|
||||
*/
|
||||
static inline FusionVector FusionAxesSwap(const FusionVector sensor, const FusionAxesAlignment alignment)
|
||||
{
|
||||
FusionVector result;
|
||||
switch (alignment) {
|
||||
case FusionAxesAlignmentPXPYPZ:
|
||||
break;
|
||||
case FusionAxesAlignmentPXNZPY:
|
||||
result.axis.x = +sensor.axis.x;
|
||||
result.axis.y = -sensor.axis.z;
|
||||
result.axis.z = +sensor.axis.y;
|
||||
return result;
|
||||
case FusionAxesAlignmentPXNYNZ:
|
||||
result.axis.x = +sensor.axis.x;
|
||||
result.axis.y = -sensor.axis.y;
|
||||
result.axis.z = -sensor.axis.z;
|
||||
return result;
|
||||
case FusionAxesAlignmentPXPZNY:
|
||||
result.axis.x = +sensor.axis.x;
|
||||
result.axis.y = +sensor.axis.z;
|
||||
result.axis.z = -sensor.axis.y;
|
||||
return result;
|
||||
case FusionAxesAlignmentNXPYNZ:
|
||||
result.axis.x = -sensor.axis.x;
|
||||
result.axis.y = +sensor.axis.y;
|
||||
result.axis.z = -sensor.axis.z;
|
||||
return result;
|
||||
case FusionAxesAlignmentNXPZPY:
|
||||
result.axis.x = -sensor.axis.x;
|
||||
result.axis.y = +sensor.axis.z;
|
||||
result.axis.z = +sensor.axis.y;
|
||||
return result;
|
||||
case FusionAxesAlignmentNXNYPZ:
|
||||
result.axis.x = -sensor.axis.x;
|
||||
result.axis.y = -sensor.axis.y;
|
||||
result.axis.z = +sensor.axis.z;
|
||||
return result;
|
||||
case FusionAxesAlignmentNXNZNY:
|
||||
result.axis.x = -sensor.axis.x;
|
||||
result.axis.y = -sensor.axis.z;
|
||||
result.axis.z = -sensor.axis.y;
|
||||
return result;
|
||||
case FusionAxesAlignmentPYNXPZ:
|
||||
result.axis.x = +sensor.axis.y;
|
||||
result.axis.y = -sensor.axis.x;
|
||||
result.axis.z = +sensor.axis.z;
|
||||
return result;
|
||||
case FusionAxesAlignmentPYNZNX:
|
||||
result.axis.x = +sensor.axis.y;
|
||||
result.axis.y = -sensor.axis.z;
|
||||
result.axis.z = -sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentPYPXNZ:
|
||||
result.axis.x = +sensor.axis.y;
|
||||
result.axis.y = +sensor.axis.x;
|
||||
result.axis.z = -sensor.axis.z;
|
||||
return result;
|
||||
case FusionAxesAlignmentPYPZPX:
|
||||
result.axis.x = +sensor.axis.y;
|
||||
result.axis.y = +sensor.axis.z;
|
||||
result.axis.z = +sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentNYPXPZ:
|
||||
result.axis.x = -sensor.axis.y;
|
||||
result.axis.y = +sensor.axis.x;
|
||||
result.axis.z = +sensor.axis.z;
|
||||
return result;
|
||||
case FusionAxesAlignmentNYNZPX:
|
||||
result.axis.x = -sensor.axis.y;
|
||||
result.axis.y = -sensor.axis.z;
|
||||
result.axis.z = +sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentNYNXNZ:
|
||||
result.axis.x = -sensor.axis.y;
|
||||
result.axis.y = -sensor.axis.x;
|
||||
result.axis.z = -sensor.axis.z;
|
||||
return result;
|
||||
case FusionAxesAlignmentNYPZNX:
|
||||
result.axis.x = -sensor.axis.y;
|
||||
result.axis.y = +sensor.axis.z;
|
||||
result.axis.z = -sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentPZPYNX:
|
||||
result.axis.x = +sensor.axis.z;
|
||||
result.axis.y = +sensor.axis.y;
|
||||
result.axis.z = -sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentPZPXPY:
|
||||
result.axis.x = +sensor.axis.z;
|
||||
result.axis.y = +sensor.axis.x;
|
||||
result.axis.z = +sensor.axis.y;
|
||||
return result;
|
||||
case FusionAxesAlignmentPZNYPX:
|
||||
result.axis.x = +sensor.axis.z;
|
||||
result.axis.y = -sensor.axis.y;
|
||||
result.axis.z = +sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentPZNXNY:
|
||||
result.axis.x = +sensor.axis.z;
|
||||
result.axis.y = -sensor.axis.x;
|
||||
result.axis.z = -sensor.axis.y;
|
||||
return result;
|
||||
case FusionAxesAlignmentNZPYPX:
|
||||
result.axis.x = -sensor.axis.z;
|
||||
result.axis.y = +sensor.axis.y;
|
||||
result.axis.z = +sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentNZNXPY:
|
||||
result.axis.x = -sensor.axis.z;
|
||||
result.axis.y = -sensor.axis.x;
|
||||
result.axis.z = +sensor.axis.y;
|
||||
return result;
|
||||
case FusionAxesAlignmentNZNYNX:
|
||||
result.axis.x = -sensor.axis.z;
|
||||
result.axis.y = -sensor.axis.y;
|
||||
result.axis.z = -sensor.axis.x;
|
||||
return result;
|
||||
case FusionAxesAlignmentNZPXNY:
|
||||
result.axis.x = -sensor.axis.z;
|
||||
result.axis.y = +sensor.axis.x;
|
||||
result.axis.z = -sensor.axis.y;
|
||||
return result;
|
||||
}
|
||||
return sensor; // avoid compiler warning
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* @file FusionCalibration.h
|
||||
* @author Seb Madgwick
|
||||
* @brief Gyroscope, accelerometer, and magnetometer calibration models.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_CALIBRATION_H
|
||||
#define FUSION_CALIBRATION_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionMath.h"
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions
|
||||
|
||||
/**
|
||||
* @brief Gyroscope and accelerometer calibration model.
|
||||
* @param uncalibrated Uncalibrated measurement.
|
||||
* @param misalignment Misalignment matrix.
|
||||
* @param sensitivity Sensitivity.
|
||||
* @param offset Offset.
|
||||
* @return Calibrated measurement.
|
||||
*/
|
||||
static inline FusionVector FusionCalibrationInertial(const FusionVector uncalibrated, const FusionMatrix misalignment,
|
||||
const FusionVector sensitivity, const FusionVector offset)
|
||||
{
|
||||
return FusionMatrixMultiplyVector(misalignment,
|
||||
FusionVectorHadamardProduct(FusionVectorSubtract(uncalibrated, offset), sensitivity));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Magnetometer calibration model.
|
||||
* @param uncalibrated Uncalibrated measurement.
|
||||
* @param softIronMatrix Soft-iron matrix.
|
||||
* @param hardIronOffset Hard-iron offset.
|
||||
* @return Calibrated measurement.
|
||||
*/
|
||||
static inline FusionVector FusionCalibrationMagnetic(const FusionVector uncalibrated, const FusionMatrix softIronMatrix,
|
||||
const FusionVector hardIronOffset)
|
||||
{
|
||||
return FusionMatrixMultiplyVector(softIronMatrix, FusionVectorSubtract(uncalibrated, hardIronOffset));
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* @file FusionCompass.c
|
||||
* @author Seb Madgwick
|
||||
* @brief Tilt-compensated compass to calculate the magnetic heading using
|
||||
* accelerometer and magnetometer measurements.
|
||||
*/
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionCompass.h"
|
||||
#include "FusionAxes.h"
|
||||
#include <math.h> // atan2f
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Functions
|
||||
|
||||
/**
|
||||
* @brief Calculates the magnetic heading.
|
||||
* @param convention Earth axes convention.
|
||||
* @param accelerometer Accelerometer measurement in any calibrated units.
|
||||
* @param magnetometer Magnetometer measurement in any calibrated units.
|
||||
* @return Heading angle in degrees.
|
||||
*/
|
||||
float FusionCompassCalculateHeading(const FusionConvention convention, const FusionVector accelerometer,
|
||||
const FusionVector magnetometer)
|
||||
{
|
||||
switch (convention) {
|
||||
case FusionConventionNwu: {
|
||||
const FusionVector west = FusionVectorNormalise(FusionVectorCrossProduct(accelerometer, magnetometer));
|
||||
const FusionVector north = FusionVectorNormalise(FusionVectorCrossProduct(west, accelerometer));
|
||||
return FusionRadiansToDegrees(atan2f(west.axis.x, north.axis.x));
|
||||
}
|
||||
case FusionConventionEnu: {
|
||||
const FusionVector west = FusionVectorNormalise(FusionVectorCrossProduct(accelerometer, magnetometer));
|
||||
const FusionVector north = FusionVectorNormalise(FusionVectorCrossProduct(west, accelerometer));
|
||||
const FusionVector east = FusionVectorMultiplyScalar(west, -1.0f);
|
||||
return FusionRadiansToDegrees(atan2f(north.axis.x, east.axis.x));
|
||||
}
|
||||
case FusionConventionNed: {
|
||||
const FusionVector up = FusionVectorMultiplyScalar(accelerometer, -1.0f);
|
||||
const FusionVector west = FusionVectorNormalise(FusionVectorCrossProduct(up, magnetometer));
|
||||
const FusionVector north = FusionVectorNormalise(FusionVectorCrossProduct(west, up));
|
||||
return FusionRadiansToDegrees(atan2f(west.axis.x, north.axis.x));
|
||||
}
|
||||
}
|
||||
return 0; // avoid compiler warning
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* @file FusionCompass.h
|
||||
* @author Seb Madgwick
|
||||
* @brief Tilt-compensated compass to calculate the magnetic heading using
|
||||
* accelerometer and magnetometer measurements.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_COMPASS_H
|
||||
#define FUSION_COMPASS_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionConvention.h"
|
||||
#include "FusionMath.h"
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Function declarations
|
||||
|
||||
float FusionCompassCalculateHeading(const FusionConvention convention, const FusionVector accelerometer,
|
||||
const FusionVector magnetometer);
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @file FusionConvention.h
|
||||
* @author Seb Madgwick
|
||||
* @brief Earth axes convention.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_CONVENTION_H
|
||||
#define FUSION_CONVENTION_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Definitions
|
||||
|
||||
/**
|
||||
* @brief Earth axes convention.
|
||||
*/
|
||||
typedef enum {
|
||||
FusionConventionNwu, /* North-West-Up */
|
||||
FusionConventionEnu, /* East-North-Up */
|
||||
FusionConventionNed, /* North-East-Down */
|
||||
} FusionConvention;
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,503 +0,0 @@
|
||||
/**
|
||||
* @file FusionMath.h
|
||||
* @author Seb Madgwick
|
||||
* @brief Math library.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_MATH_H
|
||||
#define FUSION_MATH_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include <math.h> // M_PI, sqrtf, atan2f, asinf
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Definitions
|
||||
|
||||
/**
|
||||
* @brief 3D vector.
|
||||
*/
|
||||
typedef union {
|
||||
float array[3];
|
||||
|
||||
struct {
|
||||
float x;
|
||||
float y;
|
||||
float z;
|
||||
} axis;
|
||||
} FusionVector;
|
||||
|
||||
/**
|
||||
* @brief Quaternion.
|
||||
*/
|
||||
typedef union {
|
||||
float array[4];
|
||||
|
||||
struct {
|
||||
float w;
|
||||
float x;
|
||||
float y;
|
||||
float z;
|
||||
} element;
|
||||
} FusionQuaternion;
|
||||
|
||||
/**
|
||||
* @brief 3x3 matrix in row-major order.
|
||||
* See http://en.wikipedia.org/wiki/Row-major_order
|
||||
*/
|
||||
typedef union {
|
||||
float array[3][3];
|
||||
|
||||
struct {
|
||||
float xx;
|
||||
float xy;
|
||||
float xz;
|
||||
float yx;
|
||||
float yy;
|
||||
float yz;
|
||||
float zx;
|
||||
float zy;
|
||||
float zz;
|
||||
} element;
|
||||
} FusionMatrix;
|
||||
|
||||
/**
|
||||
* @brief Euler angles. Roll, pitch, and yaw correspond to rotations around
|
||||
* X, Y, and Z respectively.
|
||||
*/
|
||||
typedef union {
|
||||
float array[3];
|
||||
|
||||
struct {
|
||||
float roll;
|
||||
float pitch;
|
||||
float yaw;
|
||||
} angle;
|
||||
} FusionEuler;
|
||||
|
||||
/**
|
||||
* @brief Vector of zeros.
|
||||
*/
|
||||
#define FUSION_VECTOR_ZERO ((FusionVector){.array = {0.0f, 0.0f, 0.0f}})
|
||||
|
||||
/**
|
||||
* @brief Vector of ones.
|
||||
*/
|
||||
#define FUSION_VECTOR_ONES ((FusionVector){.array = {1.0f, 1.0f, 1.0f}})
|
||||
|
||||
/**
|
||||
* @brief Identity quaternion.
|
||||
*/
|
||||
#define FUSION_IDENTITY_QUATERNION ((FusionQuaternion){.array = {1.0f, 0.0f, 0.0f, 0.0f}})
|
||||
|
||||
/**
|
||||
* @brief Identity matrix.
|
||||
*/
|
||||
#define FUSION_IDENTITY_MATRIX ((FusionMatrix){.array = {{1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}}})
|
||||
|
||||
/**
|
||||
* @brief Euler angles of zero.
|
||||
*/
|
||||
#define FUSION_EULER_ZERO ((FusionEuler){.array = {0.0f, 0.0f, 0.0f}})
|
||||
|
||||
/**
|
||||
* @brief Pi. May not be defined in math.h.
|
||||
*/
|
||||
#ifndef M_PI
|
||||
#define M_PI (3.14159265358979323846)
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Include this definition or add as a preprocessor definition to use
|
||||
* normal square root operations.
|
||||
*/
|
||||
// #define FUSION_USE_NORMAL_SQRT
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions - Degrees and radians conversion
|
||||
|
||||
/**
|
||||
* @brief Converts degrees to radians.
|
||||
* @param degrees Degrees.
|
||||
* @return Radians.
|
||||
*/
|
||||
static inline float FusionDegreesToRadians(const float degrees)
|
||||
{
|
||||
return degrees * ((float)M_PI / 180.0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Converts radians to degrees.
|
||||
* @param radians Radians.
|
||||
* @return Degrees.
|
||||
*/
|
||||
static inline float FusionRadiansToDegrees(const float radians)
|
||||
{
|
||||
return radians * (180.0f / (float)M_PI);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions - Arc sine
|
||||
|
||||
/**
|
||||
* @brief Returns the arc sine of the value.
|
||||
* @param value Value.
|
||||
* @return Arc sine of the value.
|
||||
*/
|
||||
static inline float FusionAsin(const float value)
|
||||
{
|
||||
if (value <= -1.0f) {
|
||||
return (float)M_PI / -2.0f;
|
||||
}
|
||||
if (value >= 1.0f) {
|
||||
return (float)M_PI / 2.0f;
|
||||
}
|
||||
return asinf(value);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions - Fast inverse square root
|
||||
|
||||
#ifndef FUSION_USE_NORMAL_SQRT
|
||||
|
||||
/**
|
||||
* @brief Calculates the reciprocal of the square root.
|
||||
* See https://pizer.wordpress.com/2008/10/12/fast-inverse-square-root/
|
||||
* @param x Operand.
|
||||
* @return Reciprocal of the square root of x.
|
||||
*/
|
||||
static inline float FusionFastInverseSqrt(const float x)
|
||||
{
|
||||
|
||||
typedef union {
|
||||
float f;
|
||||
int32_t i;
|
||||
} Union32;
|
||||
|
||||
Union32 union32 = {.f = x};
|
||||
union32.i = 0x5F1F1412 - (union32.i >> 1);
|
||||
return union32.f * (1.69000231f - 0.714158168f * x * union32.f * union32.f);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions - Vector operations
|
||||
|
||||
/**
|
||||
* @brief Returns true if the vector is zero.
|
||||
* @param vector Vector.
|
||||
* @return True if the vector is zero.
|
||||
*/
|
||||
static inline bool FusionVectorIsZero(const FusionVector vector)
|
||||
{
|
||||
return (vector.axis.x == 0.0f) && (vector.axis.y == 0.0f) && (vector.axis.z == 0.0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the sum of two vectors.
|
||||
* @param vectorA Vector A.
|
||||
* @param vectorB Vector B.
|
||||
* @return Sum of two vectors.
|
||||
*/
|
||||
static inline FusionVector FusionVectorAdd(const FusionVector vectorA, const FusionVector vectorB)
|
||||
{
|
||||
const FusionVector result = {.axis = {
|
||||
.x = vectorA.axis.x + vectorB.axis.x,
|
||||
.y = vectorA.axis.y + vectorB.axis.y,
|
||||
.z = vectorA.axis.z + vectorB.axis.z,
|
||||
}};
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns vector B subtracted from vector A.
|
||||
* @param vectorA Vector A.
|
||||
* @param vectorB Vector B.
|
||||
* @return Vector B subtracted from vector A.
|
||||
*/
|
||||
static inline FusionVector FusionVectorSubtract(const FusionVector vectorA, const FusionVector vectorB)
|
||||
{
|
||||
const FusionVector result = {.axis = {
|
||||
.x = vectorA.axis.x - vectorB.axis.x,
|
||||
.y = vectorA.axis.y - vectorB.axis.y,
|
||||
.z = vectorA.axis.z - vectorB.axis.z,
|
||||
}};
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the sum of the elements.
|
||||
* @param vector Vector.
|
||||
* @return Sum of the elements.
|
||||
*/
|
||||
static inline float FusionVectorSum(const FusionVector vector)
|
||||
{
|
||||
return vector.axis.x + vector.axis.y + vector.axis.z;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the multiplication of a vector by a scalar.
|
||||
* @param vector Vector.
|
||||
* @param scalar Scalar.
|
||||
* @return Multiplication of a vector by a scalar.
|
||||
*/
|
||||
static inline FusionVector FusionVectorMultiplyScalar(const FusionVector vector, const float scalar)
|
||||
{
|
||||
const FusionVector result = {.axis = {
|
||||
.x = vector.axis.x * scalar,
|
||||
.y = vector.axis.y * scalar,
|
||||
.z = vector.axis.z * scalar,
|
||||
}};
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Calculates the Hadamard product (element-wise multiplication).
|
||||
* @param vectorA Vector A.
|
||||
* @param vectorB Vector B.
|
||||
* @return Hadamard product.
|
||||
*/
|
||||
static inline FusionVector FusionVectorHadamardProduct(const FusionVector vectorA, const FusionVector vectorB)
|
||||
{
|
||||
const FusionVector result = {.axis = {
|
||||
.x = vectorA.axis.x * vectorB.axis.x,
|
||||
.y = vectorA.axis.y * vectorB.axis.y,
|
||||
.z = vectorA.axis.z * vectorB.axis.z,
|
||||
}};
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the cross product.
|
||||
* @param vectorA Vector A.
|
||||
* @param vectorB Vector B.
|
||||
* @return Cross product.
|
||||
*/
|
||||
static inline FusionVector FusionVectorCrossProduct(const FusionVector vectorA, const FusionVector vectorB)
|
||||
{
|
||||
#define A vectorA.axis
|
||||
#define B vectorB.axis
|
||||
const FusionVector result = {.axis = {
|
||||
.x = A.y * B.z - A.z * B.y,
|
||||
.y = A.z * B.x - A.x * B.z,
|
||||
.z = A.x * B.y - A.y * B.x,
|
||||
}};
|
||||
return result;
|
||||
#undef A
|
||||
#undef B
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the dot product.
|
||||
* @param vectorA Vector A.
|
||||
* @param vectorB Vector B.
|
||||
* @return Dot product.
|
||||
*/
|
||||
static inline float FusionVectorDotProduct(const FusionVector vectorA, const FusionVector vectorB)
|
||||
{
|
||||
return FusionVectorSum(FusionVectorHadamardProduct(vectorA, vectorB));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the vector magnitude squared.
|
||||
* @param vector Vector.
|
||||
* @return Vector magnitude squared.
|
||||
*/
|
||||
static inline float FusionVectorMagnitudeSquared(const FusionVector vector)
|
||||
{
|
||||
return FusionVectorSum(FusionVectorHadamardProduct(vector, vector));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the vector magnitude.
|
||||
* @param vector Vector.
|
||||
* @return Vector magnitude.
|
||||
*/
|
||||
static inline float FusionVectorMagnitude(const FusionVector vector)
|
||||
{
|
||||
return sqrtf(FusionVectorMagnitudeSquared(vector));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the normalised vector.
|
||||
* @param vector Vector.
|
||||
* @return Normalised vector.
|
||||
*/
|
||||
static inline FusionVector FusionVectorNormalise(const FusionVector vector)
|
||||
{
|
||||
#ifdef FUSION_USE_NORMAL_SQRT
|
||||
const float magnitudeReciprocal = 1.0f / sqrtf(FusionVectorMagnitudeSquared(vector));
|
||||
#else
|
||||
const float magnitudeReciprocal = FusionFastInverseSqrt(FusionVectorMagnitudeSquared(vector));
|
||||
#endif
|
||||
return FusionVectorMultiplyScalar(vector, magnitudeReciprocal);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions - Quaternion operations
|
||||
|
||||
/**
|
||||
* @brief Returns the sum of two quaternions.
|
||||
* @param quaternionA Quaternion A.
|
||||
* @param quaternionB Quaternion B.
|
||||
* @return Sum of two quaternions.
|
||||
*/
|
||||
static inline FusionQuaternion FusionQuaternionAdd(const FusionQuaternion quaternionA, const FusionQuaternion quaternionB)
|
||||
{
|
||||
const FusionQuaternion result = {.element = {
|
||||
.w = quaternionA.element.w + quaternionB.element.w,
|
||||
.x = quaternionA.element.x + quaternionB.element.x,
|
||||
.y = quaternionA.element.y + quaternionB.element.y,
|
||||
.z = quaternionA.element.z + quaternionB.element.z,
|
||||
}};
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the multiplication of two quaternions.
|
||||
* @param quaternionA Quaternion A (to be post-multiplied).
|
||||
* @param quaternionB Quaternion B (to be pre-multiplied).
|
||||
* @return Multiplication of two quaternions.
|
||||
*/
|
||||
static inline FusionQuaternion FusionQuaternionMultiply(const FusionQuaternion quaternionA, const FusionQuaternion quaternionB)
|
||||
{
|
||||
#define A quaternionA.element
|
||||
#define B quaternionB.element
|
||||
const FusionQuaternion result = {.element = {
|
||||
.w = A.w * B.w - A.x * B.x - A.y * B.y - A.z * B.z,
|
||||
.x = A.w * B.x + A.x * B.w + A.y * B.z - A.z * B.y,
|
||||
.y = A.w * B.y - A.x * B.z + A.y * B.w + A.z * B.x,
|
||||
.z = A.w * B.z + A.x * B.y - A.y * B.x + A.z * B.w,
|
||||
}};
|
||||
return result;
|
||||
#undef A
|
||||
#undef B
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the multiplication of a quaternion with a vector. This is a
|
||||
* normal quaternion multiplication where the vector is treated a
|
||||
* quaternion with a W element value of zero. The quaternion is post-
|
||||
* multiplied by the vector.
|
||||
* @param quaternion Quaternion.
|
||||
* @param vector Vector.
|
||||
* @return Multiplication of a quaternion with a vector.
|
||||
*/
|
||||
static inline FusionQuaternion FusionQuaternionMultiplyVector(const FusionQuaternion quaternion, const FusionVector vector)
|
||||
{
|
||||
#define Q quaternion.element
|
||||
#define V vector.axis
|
||||
const FusionQuaternion result = {.element = {
|
||||
.w = -Q.x * V.x - Q.y * V.y - Q.z * V.z,
|
||||
.x = Q.w * V.x + Q.y * V.z - Q.z * V.y,
|
||||
.y = Q.w * V.y - Q.x * V.z + Q.z * V.x,
|
||||
.z = Q.w * V.z + Q.x * V.y - Q.y * V.x,
|
||||
}};
|
||||
return result;
|
||||
#undef Q
|
||||
#undef V
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the normalised quaternion.
|
||||
* @param quaternion Quaternion.
|
||||
* @return Normalised quaternion.
|
||||
*/
|
||||
static inline FusionQuaternion FusionQuaternionNormalise(const FusionQuaternion quaternion)
|
||||
{
|
||||
#define Q quaternion.element
|
||||
#ifdef FUSION_USE_NORMAL_SQRT
|
||||
const float magnitudeReciprocal = 1.0f / sqrtf(Q.w * Q.w + Q.x * Q.x + Q.y * Q.y + Q.z * Q.z);
|
||||
#else
|
||||
const float magnitudeReciprocal = FusionFastInverseSqrt(Q.w * Q.w + Q.x * Q.x + Q.y * Q.y + Q.z * Q.z);
|
||||
#endif
|
||||
const FusionQuaternion result = {.element = {
|
||||
.w = Q.w * magnitudeReciprocal,
|
||||
.x = Q.x * magnitudeReciprocal,
|
||||
.y = Q.y * magnitudeReciprocal,
|
||||
.z = Q.z * magnitudeReciprocal,
|
||||
}};
|
||||
return result;
|
||||
#undef Q
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions - Matrix operations
|
||||
|
||||
/**
|
||||
* @brief Returns the multiplication of a matrix with a vector.
|
||||
* @param matrix Matrix.
|
||||
* @param vector Vector.
|
||||
* @return Multiplication of a matrix with a vector.
|
||||
*/
|
||||
static inline FusionVector FusionMatrixMultiplyVector(const FusionMatrix matrix, const FusionVector vector)
|
||||
{
|
||||
#define R matrix.element
|
||||
const FusionVector result = {.axis = {
|
||||
.x = R.xx * vector.axis.x + R.xy * vector.axis.y + R.xz * vector.axis.z,
|
||||
.y = R.yx * vector.axis.x + R.yy * vector.axis.y + R.yz * vector.axis.z,
|
||||
.z = R.zx * vector.axis.x + R.zy * vector.axis.y + R.zz * vector.axis.z,
|
||||
}};
|
||||
return result;
|
||||
#undef R
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Inline functions - Conversion operations
|
||||
|
||||
/**
|
||||
* @brief Converts a quaternion to a rotation matrix.
|
||||
* @param quaternion Quaternion.
|
||||
* @return Rotation matrix.
|
||||
*/
|
||||
static inline FusionMatrix FusionQuaternionToMatrix(const FusionQuaternion quaternion)
|
||||
{
|
||||
#define Q quaternion.element
|
||||
const float qwqw = Q.w * Q.w; // calculate common terms to avoid repeated operations
|
||||
const float qwqx = Q.w * Q.x;
|
||||
const float qwqy = Q.w * Q.y;
|
||||
const float qwqz = Q.w * Q.z;
|
||||
const float qxqy = Q.x * Q.y;
|
||||
const float qxqz = Q.x * Q.z;
|
||||
const float qyqz = Q.y * Q.z;
|
||||
const FusionMatrix matrix = {.element = {
|
||||
.xx = 2.0f * (qwqw - 0.5f + Q.x * Q.x),
|
||||
.xy = 2.0f * (qxqy - qwqz),
|
||||
.xz = 2.0f * (qxqz + qwqy),
|
||||
.yx = 2.0f * (qxqy + qwqz),
|
||||
.yy = 2.0f * (qwqw - 0.5f + Q.y * Q.y),
|
||||
.yz = 2.0f * (qyqz - qwqx),
|
||||
.zx = 2.0f * (qxqz - qwqy),
|
||||
.zy = 2.0f * (qyqz + qwqx),
|
||||
.zz = 2.0f * (qwqw - 0.5f + Q.z * Q.z),
|
||||
}};
|
||||
return matrix;
|
||||
#undef Q
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Converts a quaternion to ZYX Euler angles in degrees.
|
||||
* @param quaternion Quaternion.
|
||||
* @return Euler angles in degrees.
|
||||
*/
|
||||
static inline FusionEuler FusionQuaternionToEuler(const FusionQuaternion quaternion)
|
||||
{
|
||||
#define Q quaternion.element
|
||||
const float halfMinusQySquared = 0.5f - Q.y * Q.y; // calculate common terms to avoid repeated operations
|
||||
const FusionEuler euler = {.angle = {
|
||||
.roll = FusionRadiansToDegrees(atan2f(Q.w * Q.x + Q.y * Q.z, halfMinusQySquared - Q.x * Q.x)),
|
||||
.pitch = FusionRadiansToDegrees(FusionAsin(2.0f * (Q.w * Q.y - Q.z * Q.x))),
|
||||
.yaw = FusionRadiansToDegrees(atan2f(Q.w * Q.z + Q.x * Q.y, halfMinusQySquared - Q.z * Q.z)),
|
||||
}};
|
||||
return euler;
|
||||
#undef Q
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* @file FusionOffset.c
|
||||
* @author Seb Madgwick
|
||||
* @brief Gyroscope offset correction algorithm for run-time calibration of the
|
||||
* gyroscope offset.
|
||||
*/
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionOffset.h"
|
||||
#include <math.h> // fabsf
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Definitions
|
||||
|
||||
/**
|
||||
* @brief Cutoff frequency in Hz.
|
||||
*/
|
||||
#define CUTOFF_FREQUENCY (0.02f)
|
||||
|
||||
/**
|
||||
* @brief Timeout in seconds.
|
||||
*/
|
||||
#define TIMEOUT (5)
|
||||
|
||||
/**
|
||||
* @brief Threshold in degrees per second.
|
||||
*/
|
||||
#define THRESHOLD (3.0f)
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Functions
|
||||
|
||||
/**
|
||||
* @brief Initialises the gyroscope offset algorithm.
|
||||
* @param offset Gyroscope offset algorithm structure.
|
||||
* @param sampleRate Sample rate in Hz.
|
||||
*/
|
||||
void FusionOffsetInitialise(FusionOffset *const offset, const unsigned int sampleRate)
|
||||
{
|
||||
offset->filterCoefficient = 2.0f * (float)M_PI * CUTOFF_FREQUENCY * (1.0f / (float)sampleRate);
|
||||
offset->timeout = TIMEOUT * sampleRate;
|
||||
offset->timer = 0;
|
||||
offset->gyroscopeOffset = FUSION_VECTOR_ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Updates the gyroscope offset algorithm and returns the corrected
|
||||
* gyroscope measurement.
|
||||
* @param offset Gyroscope offset algorithm structure.
|
||||
* @param gyroscope Gyroscope measurement in degrees per second.
|
||||
* @return Corrected gyroscope measurement in degrees per second.
|
||||
*/
|
||||
FusionVector FusionOffsetUpdate(FusionOffset *const offset, FusionVector gyroscope)
|
||||
{
|
||||
|
||||
// Subtract offset from gyroscope measurement
|
||||
gyroscope = FusionVectorSubtract(gyroscope, offset->gyroscopeOffset);
|
||||
|
||||
// Reset timer if gyroscope not stationary
|
||||
if ((fabsf(gyroscope.axis.x) > THRESHOLD) || (fabsf(gyroscope.axis.y) > THRESHOLD) || (fabsf(gyroscope.axis.z) > THRESHOLD)) {
|
||||
offset->timer = 0;
|
||||
return gyroscope;
|
||||
}
|
||||
|
||||
// Increment timer while gyroscope stationary
|
||||
if (offset->timer < offset->timeout) {
|
||||
offset->timer++;
|
||||
return gyroscope;
|
||||
}
|
||||
|
||||
// Adjust offset if timer has elapsed
|
||||
offset->gyroscopeOffset =
|
||||
FusionVectorAdd(offset->gyroscopeOffset, FusionVectorMultiplyScalar(gyroscope, offset->filterCoefficient));
|
||||
return gyroscope;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* @file FusionOffset.h
|
||||
* @author Seb Madgwick
|
||||
* @brief Gyroscope offset correction algorithm for run-time calibration of the
|
||||
* gyroscope offset.
|
||||
*/
|
||||
|
||||
#ifndef FUSION_OFFSET_H
|
||||
#define FUSION_OFFSET_H
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Includes
|
||||
|
||||
#include "FusionMath.h"
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Definitions
|
||||
|
||||
/**
|
||||
* @brief Gyroscope offset algorithm structure. Structure members are used
|
||||
* internally and must not be accessed by the application.
|
||||
*/
|
||||
typedef struct {
|
||||
float filterCoefficient;
|
||||
unsigned int timeout;
|
||||
unsigned int timer;
|
||||
FusionVector gyroscopeOffset;
|
||||
} FusionOffset;
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Function declarations
|
||||
|
||||
void FusionOffsetInitialise(FusionOffset *const offset, const unsigned int sampleRate);
|
||||
|
||||
FusionVector FusionOffsetUpdate(FusionOffset *const offset, FusionVector gyroscope);
|
||||
|
||||
#endif
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// End of file
|
||||
@@ -1,5 +1,5 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
#include "FSCommon.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#if HAS_SCREEN
|
||||
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
|
||||
// Disable debug logging entirely on release builds of HELTEC_MESH_SOLAR for space constraints
|
||||
#if defined(HELTEC_MESH_SOLAR)
|
||||
|
||||
+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];
|
||||
|
||||
+4
-3
@@ -133,11 +133,12 @@ bool AirTime::isTxAllowedChannelUtil(bool polite)
|
||||
|
||||
bool AirTime::isTxAllowedAirUtil()
|
||||
{
|
||||
if (!config.lora.override_duty_cycle && myRegion->dutyCycle < 100) {
|
||||
if (utilizationTXPercent() < myRegion->dutyCycle * polite_duty_cycle_percent / 100) {
|
||||
float effectiveDutyCycle = getEffectiveDutyCycle();
|
||||
if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) {
|
||||
if (utilizationTXPercent() < effectiveDutyCycle * polite_duty_cycle_percent / 100) {
|
||||
return true;
|
||||
} else {
|
||||
LOG_WARN("TX air util. >%f%%. Skip send", myRegion->dutyCycle * polite_duty_cycle_percent / 100);
|
||||
LOG_WARN("TX air util. >%f%%. Skip send", effectiveDutyCycle * polite_duty_cycle_percent / 100);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#endif
|
||||
#if __has_include("SensorRtcHelper.hpp")
|
||||
#include "SensorRtcHelper.hpp"
|
||||
// SensorLib defines isBitSet as a macro; undefine it here to avoid conflicts
|
||||
// with the SparkFun MMC5983MA library, which has a class method of the same name.
|
||||
#ifdef isBitSet
|
||||
#undef isBitSet
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* Offer chance for variant-specific defines */
|
||||
@@ -243,6 +248,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define QMI8658_ADDR 0x6B
|
||||
#define QMC5883L_ADDR 0x0D
|
||||
#define HMC5883L_ADDR 0x1E
|
||||
#define MMC5983MA_ADDR 0x30
|
||||
#define SHTC3_ADDR 0x70
|
||||
#define LPS22HB_ADDR 0x5C
|
||||
#define LPS22HB_ADDR_ALT 0x5D
|
||||
@@ -292,6 +298,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define DA217_ADDR 0x26
|
||||
#define BMI270_ADDR 0x68
|
||||
#define BMI270_ADDR_ALT 0x69
|
||||
#define ICM42607P_ADDR 0x68
|
||||
#define ICM42607P_ADDR_ALT 0x69
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LED
|
||||
@@ -565,5 +573,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,8 +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};
|
||||
return firstOfOrNONE(10, 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, IIS2MDCTR};
|
||||
return firstOfOrNONE(2, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAQI() const
|
||||
|
||||
@@ -41,6 +41,7 @@ class ScanI2C
|
||||
QMI8658,
|
||||
QMC5883L,
|
||||
HMC5883L,
|
||||
MMC5983MA,
|
||||
PMSA003I,
|
||||
QMA6100P,
|
||||
MPU6050,
|
||||
@@ -65,6 +66,7 @@ class ScanI2C
|
||||
FT6336U,
|
||||
STK8BAXX,
|
||||
ICM20948,
|
||||
ICM42607P,
|
||||
SCD4X,
|
||||
MAX30102,
|
||||
TPS65233,
|
||||
@@ -97,6 +99,8 @@ class ScanI2C
|
||||
CW2015,
|
||||
SCD30,
|
||||
ADS1115,
|
||||
IIS2MDCTR,
|
||||
ISM330DHCX,
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
@@ -149,6 +153,8 @@ class ScanI2C
|
||||
|
||||
FoundDevice firstAccelerometer() const;
|
||||
|
||||
FoundDevice firstMagnetometer() const;
|
||||
|
||||
FoundDevice firstAQI() const;
|
||||
|
||||
FoundDevice firstRGBLED() const;
|
||||
|
||||
@@ -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
|
||||
@@ -179,8 +179,22 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address)
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
static uint8_t crcSHT2X(const uint8_t *data, uint8_t len)
|
||||
{
|
||||
uint8_t crc = 0;
|
||||
for (uint8_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; bit++) {
|
||||
crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : crc << 1;
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
{
|
||||
uint8_t serialA[8] = {0};
|
||||
uint8_t serialB[6] = {0};
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFA);
|
||||
@@ -189,12 +203,13 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)8) != 8)
|
||||
if (i2cBus->requestFrom(address, (uint8_t)sizeof(serialA)) != sizeof(serialA))
|
||||
return false;
|
||||
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 8) {
|
||||
i2cBus->read();
|
||||
for (uint8_t i = 0; i < sizeof(serialA); i++) {
|
||||
if (!i2cBus->available())
|
||||
return false;
|
||||
serialA[i] = i2cBus->read();
|
||||
}
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
@@ -204,16 +219,18 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)6) != 6)
|
||||
if (i2cBus->requestFrom(address, (uint8_t)sizeof(serialB)) != sizeof(serialB))
|
||||
return false;
|
||||
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 6) {
|
||||
i2cBus->read();
|
||||
for (uint8_t i = 0; i < sizeof(serialB); i++) {
|
||||
if (!i2cBus->available())
|
||||
return false;
|
||||
serialB[i] = i2cBus->read();
|
||||
}
|
||||
|
||||
// Assume we detect the SHT21 if something came back from the request
|
||||
return true;
|
||||
return crcSHT2X(&serialA[0], 1) == serialA[1] && crcSHT2X(&serialA[2], 1) == serialA[3] &&
|
||||
crcSHT2X(&serialA[4], 1) == serialA[5] && crcSHT2X(&serialA[6], 1) == serialA[7] &&
|
||||
crcSHT2X(&serialB[0], 2) == serialB[2] && crcSHT2X(&serialB[3], 2) == serialB[5];
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -343,9 +360,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address);
|
||||
#ifdef HAS_NCP5623
|
||||
SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address);
|
||||
#endif
|
||||
#ifdef HAS_LP5562
|
||||
SCAN_SIMPLE_CASE(LP5562_ADDR, LP5562, "LP5562", (uint8_t)addr.address);
|
||||
#endif
|
||||
case XPOWERS_AXP192_AXP2101_ADDRESS:
|
||||
// Do we have the axp2101/192 or the TCA8418
|
||||
@@ -570,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);
|
||||
@@ -577,12 +594,34 @@ 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
|
||||
SCAN_SIMPLE_CASE(PMSA003I_ADDR, PMSA003I, "PMSA003I", (uint8_t)addr.address)
|
||||
#endif
|
||||
case MMC5983MA_ADDR:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x2F), 1);
|
||||
if (registerValue == 0x30) {
|
||||
type = MMC5983MA;
|
||||
logFoundDevice("MMC5983MA", (uint8_t)addr.address);
|
||||
#ifdef HAS_LP5562
|
||||
} else {
|
||||
type = LP5562;
|
||||
logFoundDevice("LP5562", (uint8_t)addr.address);
|
||||
#endif
|
||||
}
|
||||
break;
|
||||
case BMA423_ADDR: // this can also be LIS3DH_ADDR_ALT
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0F), 2);
|
||||
if (registerValue == 0x3300 || registerValue == 0x3333) { // RAK4631 WisBlock has LIS3DH register at 0x3333
|
||||
@@ -737,8 +776,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
}
|
||||
break;
|
||||
|
||||
case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT, and SEN5X_ADDR
|
||||
case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR
|
||||
case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT, ICM42607P_ADDR_ALT, and SEN5X_ADDR
|
||||
case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR, and ICM42607P_ADDR
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1);
|
||||
#ifdef HAS_ICM20948
|
||||
type = ICM20948;
|
||||
@@ -758,6 +797,12 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
break;
|
||||
} else {
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x75), 1); // WHO_AM_I
|
||||
if (registerValue == 0x60) {
|
||||
type = ICM42607P;
|
||||
logFoundDevice("ICM-42607-P", (uint8_t)addr.address);
|
||||
break;
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
String prod = "";
|
||||
prod = readSEN5xProductName(i2cBus, addr.address);
|
||||
|
||||
+356
-15
@@ -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;
|
||||
}
|
||||
@@ -844,6 +1164,15 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
|
||||
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 +1431,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();
|
||||
@@ -1277,7 +1611,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 +1632,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 +1687,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 +1972,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 +2019,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,8 +155,19 @@ 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
|
||||
@@ -178,6 +189,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
|
||||
|
||||
+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;
|
||||
|
||||
@@ -1458,7 +1458,8 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON);
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
tft->displayOn();
|
||||
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
|
||||
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
tft->wakeup();
|
||||
tft->powerSaveOff();
|
||||
#endif
|
||||
@@ -1469,7 +1470,7 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
#ifdef UNPHONE
|
||||
unphone.backlight(true); // using unPhone library
|
||||
#endif
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(ST7789_CS) && \
|
||||
!defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function
|
||||
tft->setBrightness(172);
|
||||
@@ -1485,7 +1486,8 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON);
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
tft->displayOff();
|
||||
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
|
||||
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
tft->sleep();
|
||||
tft->powerSaveOn();
|
||||
#endif
|
||||
@@ -1496,7 +1498,7 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
#ifdef UNPHONE
|
||||
unphone.backlight(false); // using unPhone library
|
||||
#endif
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
|
||||
tft->setBrightness(0);
|
||||
#endif
|
||||
@@ -1511,7 +1513,7 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
|
||||
void TFTDisplay::setDisplayBrightness(uint8_t _brightness)
|
||||
{
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1)
|
||||
// todo
|
||||
#elif !defined(HACKADAY_COMMUNICATOR)
|
||||
tft->setBrightness(_brightness);
|
||||
@@ -1531,7 +1533,7 @@ bool TFTDisplay::hasTouch(void)
|
||||
{
|
||||
#ifdef RAK14014
|
||||
return true;
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->touch() != nullptr;
|
||||
#else
|
||||
return false;
|
||||
@@ -1550,7 +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)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->getTouch(x, y);
|
||||
#else
|
||||
return false;
|
||||
@@ -1567,7 +1569,7 @@ bool TFTDisplay::connect()
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
LOG_INFO("Do TFT init");
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1)
|
||||
tft = new TFT_eSPI;
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */);
|
||||
|
||||
@@ -142,8 +142,9 @@ void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offset
|
||||
if (keyboardStartY < 0)
|
||||
keyboardStartY = 0;
|
||||
} else {
|
||||
// Default (non-wide, non-64px) behavior: use key height heuristic and place at bottom
|
||||
cellH = KEY_HEIGHT;
|
||||
// Default (non-wide, non-64px) e.g. SH1107 128x128:
|
||||
// cellH = FONT_HEIGHT_SMALL - 2 so rows are tighter while still hosting the font
|
||||
cellH = std::max((int)KEY_HEIGHT, FONT_HEIGHT_SMALL - 2);
|
||||
int keyboardHeight = KEYBOARD_ROWS * cellH;
|
||||
keyboardStartY = screenH - keyboardHeight;
|
||||
if (keyboardStartY < 0)
|
||||
@@ -446,11 +447,8 @@ void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool
|
||||
if (textX < x)
|
||||
textX = x; // guard
|
||||
} else {
|
||||
if (display->getHeight() <= 64 && (key.character >= '0' && key.character <= '9')) {
|
||||
textX = x + (width - textWidth + 1) / 2;
|
||||
} else {
|
||||
textX = x + (width - textWidth) / 2;
|
||||
}
|
||||
// Use ceil rounding for all screens (consistent with 128x64 behavior for numbers)
|
||||
textX = x + (width - textWidth + 1) / 2;
|
||||
}
|
||||
int contentTop = y;
|
||||
int contentH = height;
|
||||
@@ -746,4 +744,4 @@ bool VirtualKeyboard::isTimedOut() const
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#if HAS_SCREEN
|
||||
#include "ClockRenderer.h"
|
||||
#include "Default.h"
|
||||
#include "DisplayFormatters.h"
|
||||
#include "GPS.h"
|
||||
#include "MenuHandler.h"
|
||||
#include "MeshRadio.h"
|
||||
@@ -125,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;
|
||||
|
||||
@@ -173,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[] = {
|
||||
@@ -180,6 +224,8 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
{"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US},
|
||||
{"EU_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_433},
|
||||
{"EU_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_868},
|
||||
{"EU_866", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_866},
|
||||
{"EU_868_NARROW", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_N_868},
|
||||
{"CN", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_CN},
|
||||
{"JP", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_JP},
|
||||
{"ANZ", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ANZ},
|
||||
@@ -203,6 +249,11 @@ 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},
|
||||
|
||||
};
|
||||
|
||||
constexpr size_t regionCount = sizeof(regionOptions) / sizeof(regionOptions[0]);
|
||||
@@ -224,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 (myRegion->dutyCycle < 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;
|
||||
@@ -271,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"};
|
||||
@@ -378,42 +458,64 @@ void menuHandler::FrequencySlotPicker()
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
// Maximum presets any region can have + 1 for Back
|
||||
static constexpr int MAX_PRESET_OPTIONS = 16;
|
||||
|
||||
static BannerOverlayOptions buildRegionPresetBanner()
|
||||
{
|
||||
// Static storage reused each call — safe because the banner is shown immediately after.
|
||||
static const char *optionsArray[MAX_PRESET_OPTIONS];
|
||||
static int optionsEnumArray[MAX_PRESET_OPTIONS];
|
||||
static char presetLabelBuf[MAX_PRESET_OPTIONS][12]; // scratch space for name copies
|
||||
int count = 0;
|
||||
|
||||
optionsArray[count] = "Back";
|
||||
optionsEnumArray[count++] = -1;
|
||||
|
||||
if (myRegion && myRegion->profile) {
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets();
|
||||
size_t numPresets = myRegion->getNumPresets();
|
||||
for (size_t i = 0; i < numPresets && count < MAX_PRESET_OPTIONS; ++i) {
|
||||
const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true);
|
||||
strncpy(presetLabelBuf[count], name, sizeof(presetLabelBuf[count]) - 1);
|
||||
presetLabelBuf[count][sizeof(presetLabelBuf[count]) - 1] = '\0';
|
||||
optionsArray[count] = presetLabelBuf[count];
|
||||
optionsEnumArray[count++] = static_cast<int>(presets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
int initialSelection = 0;
|
||||
for (int i = 1; i < count; ++i) {
|
||||
if (optionsEnumArray[i] == static_cast<int>(config.lora.modem_preset)) {
|
||||
initialSelection = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Radio Preset";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
bannerOptions.optionsCount = static_cast<uint8_t>(count);
|
||||
bannerOptions.InitialSelected = initialSelection;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
if (selected == -1) {
|
||||
menuHandler::menuQueue = menuHandler::LoraMenu;
|
||||
screen->runNow();
|
||||
return;
|
||||
}
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(selected);
|
||||
config.lora.channel_num = 0; // Reset to default channel for the preset
|
||||
config.lora.override_frequency = 0; // Clear any custom frequency
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
};
|
||||
return bannerOptions;
|
||||
}
|
||||
|
||||
void menuHandler::radioPresetPicker()
|
||||
{
|
||||
static const RadioPresetOption presetOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{"LongTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO},
|
||||
{"LongModerate", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE},
|
||||
{"LongFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST},
|
||||
{"MediumSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW},
|
||||
{"MediumFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST},
|
||||
{"ShortSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW},
|
||||
{"ShortFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST},
|
||||
{"ShortTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO},
|
||||
};
|
||||
|
||||
constexpr size_t presetCount = sizeof(presetOptions) / sizeof(presetOptions[0]);
|
||||
static std::array<const char *, presetCount> presetLabels{};
|
||||
|
||||
auto bannerOptions =
|
||||
createStaticBannerOptions("Radio Preset", presetOptions, presetLabels, [](const RadioPresetOption &option, int) -> void {
|
||||
if (option.action == OptionsAction::Back) {
|
||||
menuHandler::menuQueue = menuHandler::LoraMenu;
|
||||
screen->runNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!option.hasValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.lora.modem_preset = option.value;
|
||||
config.lora.channel_num = 0; // Reset to default channel for the preset
|
||||
config.lora.override_frequency = 0; // Clear any custom frequency
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
});
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
screen->showOverlayBanner(buildRegionPresetBanner());
|
||||
}
|
||||
|
||||
void menuHandler::twelveHourPicker()
|
||||
@@ -1307,6 +1409,11 @@ void menuHandler::positionBaseMenu()
|
||||
if (accelerometerThread) {
|
||||
accelerometerThread->calibrate(30);
|
||||
}
|
||||
#endif
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_MAGNETOMETER
|
||||
if (magnetometerThread) {
|
||||
magnetometerThread->calibrate(30);
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
case PositionAction::GPSSmartPosition:
|
||||
@@ -2787,6 +2894,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],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#if HAS_SCREEN
|
||||
#include "CompassRenderer.h"
|
||||
#include "GPSStatus.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "NodeListRenderer.h"
|
||||
@@ -78,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,
|
||||
@@ -816,16 +819,16 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
// Helper to get SNR limit based on modem preset
|
||||
auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float {
|
||||
switch (preset) {
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
|
||||
case PRESET(LONG_SLOW):
|
||||
case PRESET(LONG_MODERATE):
|
||||
case PRESET(LONG_FAST):
|
||||
return -6.0f;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
|
||||
case PRESET(MEDIUM_SLOW):
|
||||
case PRESET(MEDIUM_FAST):
|
||||
return -5.5f;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
|
||||
case PRESET(SHORT_SLOW):
|
||||
case PRESET(SHORT_FAST):
|
||||
case PRESET(SHORT_TURBO):
|
||||
return -4.5f;
|
||||
default:
|
||||
return -6.0f;
|
||||
@@ -1141,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);
|
||||
|
||||
|
||||
@@ -64,6 +64,12 @@ enum MenuAction {
|
||||
SET_REGION_KZ_863,
|
||||
SET_REGION_NP_865,
|
||||
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,
|
||||
@@ -78,6 +84,13 @@ enum MenuAction {
|
||||
SET_PRESET_SHORT_SLOW,
|
||||
SET_PRESET_SHORT_FAST,
|
||||
SET_PRESET_SHORT_TURBO,
|
||||
SET_PRESET_LITE_SLOW,
|
||||
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,
|
||||
SET_TZ_US_ALASKA,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "DisplayFormatters.h"
|
||||
#include "GPS.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "RTC.h"
|
||||
#include "Router.h"
|
||||
@@ -257,6 +258,11 @@ int32_t InkHUD::MenuApplet::runOnce()
|
||||
return OSThread::disable();
|
||||
}
|
||||
|
||||
// Storage for the dynamically-built region preset list — populated in showPage(NODE_CONFIG_PRESET)
|
||||
static constexpr uint8_t MAX_REGION_PRESETS = 16;
|
||||
static meshtastic_Config_LoRaConfig_ModemPreset regionPresets[MAX_REGION_PRESETS];
|
||||
static uint8_t regionPresetCount = 0;
|
||||
|
||||
static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
{
|
||||
if (config.lora.region == region)
|
||||
@@ -276,12 +282,12 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
|
||||
initRegion();
|
||||
|
||||
if (myRegion && myRegion->dutyCycle < 100) {
|
||||
if (myRegion && getEffectiveDutyCycle() < 100) {
|
||||
config.lora.ignore_mqtt = true;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -770,6 +776,30 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_BR_902);
|
||||
break;
|
||||
|
||||
case SET_REGION_EU_866:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
|
||||
break;
|
||||
|
||||
case SET_REGION_NARROW_868:
|
||||
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);
|
||||
@@ -789,37 +819,62 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
|
||||
// Presets
|
||||
case SET_PRESET_LONG_SLOW:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW);
|
||||
applyLoRaPreset(PRESET(LONG_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_LONG_MODERATE:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE);
|
||||
applyLoRaPreset(PRESET(LONG_MODERATE));
|
||||
break;
|
||||
|
||||
case SET_PRESET_LONG_FAST:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
||||
applyLoRaPreset(PRESET(LONG_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_MEDIUM_SLOW:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW);
|
||||
applyLoRaPreset(PRESET(MEDIUM_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_MEDIUM_FAST:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
|
||||
applyLoRaPreset(PRESET(MEDIUM_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_SHORT_SLOW:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW);
|
||||
applyLoRaPreset(PRESET(SHORT_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_SHORT_FAST:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST);
|
||||
applyLoRaPreset(PRESET(SHORT_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_SHORT_TURBO:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
|
||||
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;
|
||||
if (index < regionPresetCount) {
|
||||
applyLoRaPreset(regionPresets[index]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Timezones
|
||||
case SET_TZ_US_HAWAII:
|
||||
applyTimezone("HST10");
|
||||
@@ -1421,6 +1476,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("US", MenuAction::SET_REGION_US, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 868", MenuAction::SET_REGION_EU_868, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 433", MenuAction::SET_REGION_EU_433, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 866", MenuAction::SET_REGION_EU_866, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 868 Narrow", MenuAction::SET_REGION_NARROW_868, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("CN", MenuAction::SET_REGION_CN, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("JP", MenuAction::SET_REGION_JP, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ANZ", MenuAction::SET_REGION_ANZ, MenuPage::EXIT));
|
||||
@@ -1444,19 +1501,27 @@ 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;
|
||||
|
||||
case NODE_CONFIG_PRESET: {
|
||||
previousPage = MenuPage::NODE_CONFIG_LORA;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
items.push_back(MenuItem("Long Moderate", MenuAction::SET_PRESET_LONG_MODERATE, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Long Fast", MenuAction::SET_PRESET_LONG_FAST, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Medium Slow", MenuAction::SET_PRESET_MEDIUM_SLOW, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Medium Fast", MenuAction::SET_PRESET_MEDIUM_FAST, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Short Slow", MenuAction::SET_PRESET_SHORT_SLOW, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Short Fast", MenuAction::SET_PRESET_SHORT_FAST, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Short Turbo", MenuAction::SET_PRESET_SHORT_TURBO, MenuPage::EXIT));
|
||||
regionPresetCount = 0;
|
||||
if (myRegion && myRegion->profile) {
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets();
|
||||
size_t numPresets = myRegion->getNumPresets();
|
||||
for (size_t i = 0; i < numPresets && regionPresetCount < MAX_REGION_PRESETS; ++i) {
|
||||
regionPresets[regionPresetCount++] = presets[i];
|
||||
const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true);
|
||||
nodeConfigLabels.emplace_back(name);
|
||||
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_PRESET_FROM_REGION, MenuPage::EXIT));
|
||||
}
|
||||
}
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "./NotificationApplet.h"
|
||||
|
||||
#include "./Notification.h"
|
||||
#include "MessageStore.h"
|
||||
#include "graphics/niche/InkHUD/Persistence.h"
|
||||
|
||||
#include "meshUtils.h"
|
||||
@@ -231,7 +232,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
||||
bool msgIsBroadcast = currentNotification.type == Notification::Type::NOTIFICATION_MESSAGE_BROADCAST;
|
||||
|
||||
// Pick source of message
|
||||
const MessageStore::Message *message =
|
||||
const StoredMessage *message =
|
||||
msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm;
|
||||
|
||||
// Find info about the sender
|
||||
@@ -261,7 +262,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
||||
text += hexifyNodeNum(message->sender);
|
||||
|
||||
text += ": ";
|
||||
text += message->text;
|
||||
text += MessageStore::getText(*message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "./AllMessageApplet.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
void InkHUD::AllMessageApplet::onActivate()
|
||||
@@ -37,7 +39,7 @@ int InkHUD::AllMessageApplet::onReceiveTextMessage(const meshtastic_MeshPacket *
|
||||
void InkHUD::AllMessageApplet::onRender(bool full)
|
||||
{
|
||||
// Find newest message, regardless of whether DM or broadcast
|
||||
MessageStore::Message *message;
|
||||
StoredMessage *message;
|
||||
if (latestMessage->wasBroadcast)
|
||||
message = &latestMessage->broadcast;
|
||||
else
|
||||
@@ -96,7 +98,7 @@ void InkHUD::AllMessageApplet::onRender(bool full)
|
||||
// ===================
|
||||
|
||||
// Parse any non-ascii chars in the message
|
||||
std::string text = parse(message->text);
|
||||
std::string text = parse(std::string(MessageStore::getText(*message)));
|
||||
|
||||
// Extra gap below the header
|
||||
int16_t textTop = headerDivY + padDivH;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "./DMApplet.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
void InkHUD::DMApplet::onActivate()
|
||||
@@ -92,7 +94,7 @@ void InkHUD::DMApplet::onRender(bool full)
|
||||
// ===================
|
||||
|
||||
// Parse any non-ascii chars in the message
|
||||
std::string text = parse(latestMessage->dm.text);
|
||||
std::string text = parse(std::string(MessageStore::getText(latestMessage->dm)));
|
||||
|
||||
// Extra gap below the header
|
||||
int16_t textTop = headerDivY + padDivH;
|
||||
|
||||
@@ -7,19 +7,9 @@
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
// Hard limits on how much message data to write to flash
|
||||
// Avoid filling the storage if something goes wrong
|
||||
// Normal usage should be well below this size
|
||||
constexpr uint8_t MAX_MESSAGES_SAVED = 10;
|
||||
constexpr uint32_t MAX_MESSAGE_SIZE = 250;
|
||||
|
||||
InkHUD::ThreadedMessageApplet::ThreadedMessageApplet(uint8_t channelIndex)
|
||||
: SinglePortModule("ThreadedMessageApplet", meshtastic_PortNum_TEXT_MESSAGE_APP), channelIndex(channelIndex)
|
||||
{
|
||||
// Create the message store
|
||||
// Will shortly attempt to load messages from RAM, if applet is active
|
||||
// Label (filename in flash) is set from channel index
|
||||
store = new MessageStore("ch" + to_string(channelIndex));
|
||||
}
|
||||
|
||||
void InkHUD::ThreadedMessageApplet::onRender(bool full)
|
||||
@@ -61,17 +51,24 @@ void InkHUD::ThreadedMessageApplet::onRender(bool full)
|
||||
const uint16_t msgW = (msgR - msgL) + 1;
|
||||
|
||||
int16_t msgB = height() - 1; // Vertical cursor for drawing. Messages are bottom-aligned to this value.
|
||||
uint8_t i = 0; // Index of stored message
|
||||
|
||||
// Loop over messages
|
||||
// - until no messages left, or
|
||||
// - until no part of message fits on screen
|
||||
while (msgB >= (0 - fontSmall.lineHeight()) && i < store->messages.size()) {
|
||||
// Iterate the global store newest-first, showing only broadcast messages on our channel
|
||||
const auto &allMessages = messageStore.getLiveMessages();
|
||||
int msgIdx = (int)allMessages.size() - 1;
|
||||
|
||||
while (msgB >= (0 - fontSmall.lineHeight()) && msgIdx >= 0) {
|
||||
|
||||
const StoredMessage &m = allMessages.at(msgIdx);
|
||||
|
||||
// Skip messages that don't belong to this channel or are DMs
|
||||
if (m.type != MessageType::BROADCAST || m.channelIndex != channelIndex) {
|
||||
msgIdx--;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grab data for message
|
||||
const MessageStore::Message &m = store->messages.at(i);
|
||||
bool outgoing = (m.sender == 0) || (m.sender == myNodeInfo.my_node_num); // Own NodeNum if canned message
|
||||
std::string bodyText = parse(m.text); // Parse any non-ascii chars in the message
|
||||
bool outgoing = (m.sender == myNodeInfo.my_node_num);
|
||||
std::string bodyText = parse(std::string(MessageStore::getText(m))); // Parse any non-ascii chars
|
||||
|
||||
// Cache bottom Y of message text
|
||||
// - Used when drawing vertical line alongside
|
||||
@@ -152,18 +149,13 @@ void InkHUD::ThreadedMessageApplet::onRender(bool full)
|
||||
// Move cursor up: padding before next message
|
||||
msgB -= fontSmall.lineHeight() * 0.5;
|
||||
|
||||
i++;
|
||||
msgIdx--;
|
||||
} // End of loop: drawing each message
|
||||
|
||||
// Fade effect:
|
||||
// Area immediately below the divider. Overdraw with sparse white lines.
|
||||
// Make text appear to pass behind the header
|
||||
hatchRegion(0, dividerY + 1, width(), fontSmall.lineHeight() / 3, 2, WHITE);
|
||||
|
||||
// If we've run out of screen to draw messages, we can drop any leftover data from the queue
|
||||
// Those messages have been pushed off the screen-top by newer ones
|
||||
while (i < store->messages.size())
|
||||
store->messages.pop_back();
|
||||
}
|
||||
|
||||
// Code which runs when the applet begins running
|
||||
@@ -198,16 +190,8 @@ ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_Me
|
||||
if (mp.to != NODENUM_BROADCAST)
|
||||
return ProcessMessage::CONTINUE;
|
||||
|
||||
// Extract info into our slimmed-down "StoredMessage" type
|
||||
MessageStore::Message newMessage;
|
||||
newMessage.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time
|
||||
newMessage.sender = mp.from;
|
||||
newMessage.channelIndex = mp.channel;
|
||||
newMessage.text = std::string((const char *)mp.decoded.payload.bytes, mp.decoded.payload.size);
|
||||
|
||||
// Store newest message at front
|
||||
// These records are used when rendering, and also stored in flash at shutdown
|
||||
store->messages.push_front(newMessage);
|
||||
// Store in the global messageStore — this handles sender, timestamp, channel, text, and ack status
|
||||
messageStore.addFromPacket(mp);
|
||||
|
||||
// If this was an incoming message, suggest that our applet becomes foreground, if permitted
|
||||
if (getFrom(&mp) != nodeDB->getNodeNum())
|
||||
@@ -232,37 +216,25 @@ bool InkHUD::ThreadedMessageApplet::approveNotification(Notification &n)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save several recent messages to flash
|
||||
// Stores the contents of ThreadedMessageApplet::messages
|
||||
// Just enough messages to fill the display
|
||||
// Messages are packed "back-to-back", to minimize blocks of flash used
|
||||
// Save messages to flash via the global messageStore.
|
||||
// The global store holds messages for all channels; no per-channel file is needed.
|
||||
void InkHUD::ThreadedMessageApplet::saveMessagesToFlash()
|
||||
{
|
||||
// Create a label (will become the filename in flash)
|
||||
std::string label = "ch" + to_string(channelIndex);
|
||||
|
||||
store->saveToFlash();
|
||||
messageStore.saveToFlash();
|
||||
}
|
||||
|
||||
// Load recent messages to flash
|
||||
// Fills ThreadedMessageApplet::messages with previous messages
|
||||
// Just enough messages have been stored to cover the display
|
||||
// Messages are loaded once by InkHUD::begin() before applets start.
|
||||
// Nothing to do here at per-applet activation time.
|
||||
void InkHUD::ThreadedMessageApplet::loadMessagesFromFlash()
|
||||
{
|
||||
// Create a label (will become the filename in flash)
|
||||
std::string label = "ch" + to_string(channelIndex);
|
||||
|
||||
store->loadFromFlash();
|
||||
// No-op: messageStore.loadFromFlash() is called in InkHUD::begin()
|
||||
}
|
||||
|
||||
// Code to run when device is shutting down
|
||||
// This is in addition to any onDeactivate() code, which will also run
|
||||
// Todo: implement before a reboot also
|
||||
void InkHUD::ThreadedMessageApplet::onShutdown()
|
||||
{
|
||||
// Save our current set of messages to flash, provided the applet isn't disabled
|
||||
if (isActive())
|
||||
saveMessagesToFlash();
|
||||
// messageStore.saveToFlash() is called centrally by Events::beforeDeepSleep / beforeReboot
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -20,8 +20,8 @@ Suggest a max of two channel, to minimize fs usage?
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
#include "graphics/niche/InkHUD/Applet.h"
|
||||
#include "graphics/niche/InkHUD/MessageStore.h"
|
||||
|
||||
#include "modules/TextMessageModule.h"
|
||||
|
||||
@@ -49,7 +49,6 @@ class ThreadedMessageApplet : public Applet, public SinglePortModule
|
||||
void saveMessagesToFlash();
|
||||
void loadMessagesFromFlash();
|
||||
|
||||
MessageStore *store; // Messages, held in RAM for use, ready to save to flash on shutdown
|
||||
uint8_t channelIndex = 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "./Events.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RTC.h"
|
||||
#include "buzz.h"
|
||||
@@ -514,6 +515,7 @@ int InkHUD::Events::beforeReboot(void *unused)
|
||||
inkhud->persistence->saveLatestMessage();
|
||||
} else {
|
||||
NicheGraphics::clearFlashData();
|
||||
messageStore.clearAllMessages(); // also wipe the shared message store
|
||||
}
|
||||
|
||||
// Note: no forceUpdate call here
|
||||
@@ -532,32 +534,27 @@ int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet)
|
||||
if (getFrom(packet) == nodeDB->getNodeNum())
|
||||
return 0;
|
||||
|
||||
// Determine whether the message is broadcast or a DM
|
||||
// Store this info to prevent confusion after a reboot
|
||||
// Avoids need to compare timestamps, because of situation where "future" messages block newly received, if time not set
|
||||
inkhud->persistence->latestMessage.wasBroadcast = isBroadcast(packet->to);
|
||||
bool isBroadcastMsg = isBroadcast(packet->to);
|
||||
inkhud->persistence->latestMessage.wasBroadcast = isBroadcastMsg;
|
||||
|
||||
// Pick the appropriate variable to store the message in
|
||||
MessageStore::Message *storedMessage = inkhud->persistence->latestMessage.wasBroadcast
|
||||
? &inkhud->persistence->latestMessage.broadcast
|
||||
: &inkhud->persistence->latestMessage.dm;
|
||||
|
||||
// Store nodenum of the sender
|
||||
// Applets can use this to fetch user data from nodedb, if they want
|
||||
storedMessage->sender = packet->from;
|
||||
|
||||
// Store the time (epoch seconds) when message received
|
||||
storedMessage->timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time
|
||||
|
||||
// Store the channel
|
||||
// - (potentially) used to determine whether notification shows
|
||||
// - (potentially) used to determine which applet to focus
|
||||
storedMessage->channelIndex = packet->channel;
|
||||
|
||||
// Store the text
|
||||
// Need to specify manually how many bytes, because source not null-terminated
|
||||
storedMessage->text =
|
||||
std::string(&packet->decoded.payload.bytes[0], &packet->decoded.payload.bytes[packet->decoded.payload.size]);
|
||||
if (!isBroadcastMsg) {
|
||||
// DMs never pass through ThreadedMessageApplet, so add them to the global store here
|
||||
// so they survive reboots. Derive the latestMessage cache entry from the stored result.
|
||||
inkhud->persistence->latestMessage.dm = messageStore.addFromPacket(*packet);
|
||||
} else {
|
||||
// Broadcasts are added to the global store by ThreadedMessageApplet::handleReceived().
|
||||
// Here we only update the latestMessage cache used by AllMessageApplet / NotificationApplet.
|
||||
StoredMessage &sm = inkhud->persistence->latestMessage.broadcast;
|
||||
sm.sender = packet->from;
|
||||
sm.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true);
|
||||
sm.channelIndex = packet->channel;
|
||||
const char *payload = reinterpret_cast<const char *>(packet->decoded.payload.bytes);
|
||||
size_t storedLen = packet->decoded.payload.size;
|
||||
if (storedLen >= MAX_MESSAGE_SIZE)
|
||||
storedLen = MAX_MESSAGE_SIZE - 1;
|
||||
sm.textOffset = MessageStore::storeText(payload, storedLen);
|
||||
sm.textLength = static_cast<uint16_t>(storedLen);
|
||||
}
|
||||
|
||||
return 0; // Tell caller to continue notifying other observers. (No reason to abort this event)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#include "./SystemApplet.h"
|
||||
#include "./Tile.h"
|
||||
#include "./WindowManager.h"
|
||||
#include "FSCommon.h"
|
||||
#include "MessageStore.h"
|
||||
#include "SPILock.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
@@ -60,11 +64,102 @@ void InkHUD::InkHUD::notifyApplyingChanges()
|
||||
}
|
||||
}
|
||||
|
||||
// One-time migration from the old per-channel InkHUD message files (/NicheGraphics/ch*.msgs)
|
||||
// to the firmware-wide MessageStore format (/Messages_default.msgs).
|
||||
// Only runs when the new store loaded empty, meaning this is the first boot after the format change.
|
||||
// Old files are deleted once migrated.
|
||||
static void migrateOldInkHUDMessages()
|
||||
{
|
||||
#ifdef FSCom
|
||||
bool migrated = false;
|
||||
constexpr uint8_t MAX_CHANNELS = 8;
|
||||
constexpr uint32_t OLD_MAX_MSG_SIZE = 250;
|
||||
|
||||
for (uint8_t ch = 0; ch < MAX_CHANNELS; ch++) {
|
||||
std::string path = "/NicheGraphics/ch";
|
||||
path += std::to_string(ch);
|
||||
path += ".msgs";
|
||||
|
||||
spiLock->lock();
|
||||
bool exists = FSCom.exists(path.c_str());
|
||||
spiLock->unlock();
|
||||
if (!exists)
|
||||
continue;
|
||||
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
|
||||
auto f = FSCom.open(path.c_str(), FILE_O_READ);
|
||||
if (!f || f.size() == 0) {
|
||||
if (f)
|
||||
f.close();
|
||||
FSCom.remove(path.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
uint8_t count = 0;
|
||||
f.readBytes(reinterpret_cast<char *>(&count), 1);
|
||||
|
||||
std::vector<StoredMessage> channelMsgs;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
StoredMessage sm;
|
||||
f.readBytes(reinterpret_cast<char *>(&sm.timestamp), sizeof(sm.timestamp));
|
||||
f.readBytes(reinterpret_cast<char *>(&sm.sender), sizeof(sm.sender));
|
||||
f.readBytes(reinterpret_cast<char *>(&sm.channelIndex), sizeof(sm.channelIndex));
|
||||
|
||||
char textBuf[OLD_MAX_MSG_SIZE + 1] = {};
|
||||
uint32_t textLen = 0;
|
||||
char c;
|
||||
while (textLen < OLD_MAX_MSG_SIZE) {
|
||||
if (f.readBytes(&c, 1) != 1)
|
||||
break;
|
||||
if (c == '\0')
|
||||
break;
|
||||
textBuf[textLen++] = c;
|
||||
}
|
||||
|
||||
sm.dest = NODENUM_BROADCAST;
|
||||
sm.type = MessageType::BROADCAST;
|
||||
sm.isBootRelative = false;
|
||||
sm.ackStatus = AckStatus::ACKED;
|
||||
size_t storedLen = (textLen >= MAX_MESSAGE_SIZE) ? MAX_MESSAGE_SIZE - 1 : textLen;
|
||||
sm.textOffset = MessageStore::storeText(textBuf, storedLen);
|
||||
sm.textLength = static_cast<uint16_t>(storedLen);
|
||||
|
||||
channelMsgs.push_back(sm);
|
||||
}
|
||||
|
||||
// Old format stored newest-first (push_front); insert oldest-first for correct chronological order
|
||||
for (int i = static_cast<int>(channelMsgs.size()) - 1; i >= 0; i--)
|
||||
messageStore.addLiveMessage(channelMsgs[i]);
|
||||
if (!channelMsgs.empty())
|
||||
migrated = true;
|
||||
|
||||
f.close();
|
||||
FSCom.remove(path.c_str());
|
||||
LOG_INFO("Migrated %u messages from %s", static_cast<uint32_t>(count), path.c_str());
|
||||
}
|
||||
|
||||
// Delete the old latest.msgs; the latestMessage cache will be re-derived from migrated channel messages
|
||||
spiLock->lock();
|
||||
if (FSCom.exists("/NicheGraphics/latest.msgs"))
|
||||
FSCom.remove("/NicheGraphics/latest.msgs");
|
||||
spiLock->unlock();
|
||||
|
||||
if (migrated) {
|
||||
LOG_INFO("InkHUD message migration complete, saving to new format");
|
||||
messageStore.saveToFlash();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Start InkHUD!
|
||||
// Call this only after you have configured InkHUD
|
||||
void InkHUD::InkHUD::begin()
|
||||
{
|
||||
persistence->loadSettings();
|
||||
messageStore.loadFromFlash(); // Load persisted messages before deriving latestMessage cache
|
||||
if (messageStore.getLiveMessages().empty())
|
||||
migrateOldInkHUDMessages(); // First boot after format change: import old per-channel files
|
||||
persistence->loadLatestMessage();
|
||||
|
||||
windowManager->begin();
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
#include "./MessageStore.h"
|
||||
|
||||
#include "SafeFile.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
// Hard limits on how much message data to write to flash
|
||||
// Avoid filling the storage if something goes wrong
|
||||
// Normal usage should be well below this size
|
||||
constexpr uint8_t MAX_MESSAGES_SAVED = 10;
|
||||
constexpr uint32_t MAX_MESSAGE_SIZE = 250;
|
||||
|
||||
InkHUD::MessageStore::MessageStore(const std::string &label)
|
||||
{
|
||||
filename = "";
|
||||
filename += "/NicheGraphics";
|
||||
filename += "/";
|
||||
filename += label;
|
||||
filename += ".msgs";
|
||||
}
|
||||
|
||||
// Write the contents of the MessageStore::messages object to flash
|
||||
// Takes the firmware's SPI lock during FS operations. Implemented for consistency, but only relevant when using SD card.
|
||||
// Need to lock and unlock around specific FS methods, as the SafeFile class takes the lock for itself internally
|
||||
void InkHUD::MessageStore::saveToFlash()
|
||||
{
|
||||
assert(!filename.empty());
|
||||
|
||||
#ifdef FSCom
|
||||
// Make the directory, if doesn't already exist
|
||||
// This is the same directory accessed by NicheGraphics::FlashData
|
||||
spiLock->lock();
|
||||
FSCom.mkdir("/NicheGraphics");
|
||||
spiLock->unlock();
|
||||
|
||||
// Open or create the file
|
||||
// No "full atomic": don't save then rename
|
||||
auto f = SafeFile(filename.c_str(), false);
|
||||
|
||||
LOG_INFO("Saving messages in %s", filename.c_str());
|
||||
|
||||
// Take firmware's SPI Lock while writing
|
||||
spiLock->lock();
|
||||
|
||||
// 1st byte: how many messages will be written to store
|
||||
f.write(messages.size());
|
||||
|
||||
// For each message
|
||||
for (uint8_t i = 0; i < messages.size() && i < MAX_MESSAGES_SAVED; i++) {
|
||||
Message &m = messages.at(i);
|
||||
f.write(reinterpret_cast<const uint8_t *>(&m.timestamp), sizeof(m.timestamp)); // Write timestamp. 4 bytes
|
||||
f.write(reinterpret_cast<const uint8_t *>(&m.sender), sizeof(m.sender)); // Write sender NodeId. 4 Bytes
|
||||
f.write(reinterpret_cast<const uint8_t *>(&m.channelIndex), sizeof(m.channelIndex)); // Write channel index. 1 Byte
|
||||
f.write(reinterpret_cast<const uint8_t *>(m.text.c_str()),
|
||||
min((size_t)MAX_MESSAGE_SIZE, m.text.size())); // Write message text
|
||||
f.write('\0'); // Append null term
|
||||
LOG_DEBUG("Wrote message %u, length %u, text \"%s\"", static_cast<uint32_t>(i),
|
||||
min((size_t)MAX_MESSAGE_SIZE, m.text.size()), m.text.c_str());
|
||||
}
|
||||
|
||||
// Release firmware's SPI lock, because SafeFile::close needs it
|
||||
spiLock->unlock();
|
||||
|
||||
bool writeSucceeded = f.close();
|
||||
|
||||
if (!writeSucceeded) {
|
||||
LOG_ERROR("Can't write data!");
|
||||
}
|
||||
#else
|
||||
LOG_ERROR("ERROR: Filesystem not implemented\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Attempt to load the previous contents of the MessageStore:message deque from flash.
|
||||
// Filename is controlled by the "label" parameter
|
||||
// Takes the firmware's SPI lock during FS operations. Implemented for consistency, but only relevant when using SD card.
|
||||
void InkHUD::MessageStore::loadFromFlash()
|
||||
{
|
||||
// Hopefully redundant. Initial intention is to only load / save once per boot.
|
||||
messages.clear();
|
||||
|
||||
#ifdef FSCom
|
||||
|
||||
// Take the firmware's SPI Lock, in case filesystem is on SD card
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
|
||||
// Check that the file *does* actually exist
|
||||
if (!FSCom.exists(filename.c_str())) {
|
||||
LOG_WARN("'%s' not found. Using default values", filename.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the file *does* actually exist
|
||||
if (!FSCom.exists(filename.c_str())) {
|
||||
LOG_INFO("'%s' not found.", filename.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the file
|
||||
auto f = FSCom.open(filename.c_str(), FILE_O_READ);
|
||||
|
||||
if (f.size() == 0) {
|
||||
LOG_INFO("%s is empty", filename.c_str());
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// If opened, start reading
|
||||
if (f) {
|
||||
LOG_INFO("Loading threaded messages '%s'", filename.c_str());
|
||||
|
||||
// First byte: how many messages are in the flash store
|
||||
uint8_t flashMessageCount = 0;
|
||||
f.readBytes(reinterpret_cast<char *>(&flashMessageCount), 1);
|
||||
LOG_DEBUG("Messages available: %u", static_cast<uint32_t>(flashMessageCount));
|
||||
|
||||
// For each message
|
||||
for (uint8_t i = 0; i < flashMessageCount && i < MAX_MESSAGES_SAVED; i++) {
|
||||
Message m;
|
||||
|
||||
// Read meta data (fixed width)
|
||||
f.readBytes(reinterpret_cast<char *>(&m.timestamp), sizeof(m.timestamp));
|
||||
f.readBytes(reinterpret_cast<char *>(&m.sender), sizeof(m.sender));
|
||||
f.readBytes(reinterpret_cast<char *>(&m.channelIndex), sizeof(m.channelIndex));
|
||||
|
||||
// Read characters until we find a null term
|
||||
char c;
|
||||
while (m.text.size() < MAX_MESSAGE_SIZE) {
|
||||
f.readBytes(&c, 1);
|
||||
if (c != '\0')
|
||||
m.text += c;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
// Store in RAM
|
||||
messages.push_back(m);
|
||||
|
||||
LOG_DEBUG("#%u, timestamp=%u, sender(num)=%u, text=\"%s\"", static_cast<uint32_t>(i), m.timestamp, m.sender,
|
||||
m.text.c_str());
|
||||
}
|
||||
|
||||
f.close();
|
||||
} else {
|
||||
LOG_ERROR("Could not open / read %s", filename.c_str());
|
||||
}
|
||||
#else
|
||||
LOG_ERROR("Filesystem not implemented");
|
||||
state = LoadFileState::NO_FILESYSTEM;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,47 +0,0 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
/*
|
||||
|
||||
We hold a few recent messages, for features like the threaded message applet.
|
||||
This class contains a struct for storing those messages,
|
||||
and methods for serializing them to flash.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include <deque>
|
||||
|
||||
#include "mesh/MeshTypes.h"
|
||||
|
||||
namespace NicheGraphics::InkHUD
|
||||
{
|
||||
|
||||
class MessageStore
|
||||
{
|
||||
public:
|
||||
// A stored message
|
||||
struct Message {
|
||||
uint32_t timestamp; // Epoch seconds
|
||||
NodeNum sender = 0;
|
||||
uint8_t channelIndex;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
MessageStore() = delete;
|
||||
explicit MessageStore(const std::string &label); // Label determines filename in flash
|
||||
|
||||
void saveToFlash();
|
||||
void loadFromFlash();
|
||||
|
||||
std::deque<Message> messages; // Interact with this object!
|
||||
|
||||
private:
|
||||
std::string filename;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
@@ -17,23 +17,23 @@ void InkHUD::Persistence::loadSettings()
|
||||
LOG_WARN("Settings version changed. Using defaults");
|
||||
}
|
||||
|
||||
// Load settings and latestMessage data
|
||||
// Rebuild the latestMessage cache from the global messageStore.
|
||||
// Called after messageStore.loadFromFlash() so that the most recent broadcast and DM
|
||||
// are immediately available to applets (DMApplet, AllMessageApplet, NotificationApplet).
|
||||
void InkHUD::Persistence::loadLatestMessage()
|
||||
{
|
||||
// Load previous "latestMessages" data from flash
|
||||
MessageStore store("latest");
|
||||
store.loadFromFlash();
|
||||
|
||||
// Place into latestMessage struct, for convenient access
|
||||
// Number of strings loaded determines whether last message was broadcast or dm
|
||||
if (store.messages.size() == 1) {
|
||||
latestMessage.dm = store.messages.at(0);
|
||||
latestMessage.wasBroadcast = false;
|
||||
} else if (store.messages.size() == 2) {
|
||||
latestMessage.dm = store.messages.at(0);
|
||||
latestMessage.broadcast = store.messages.at(1);
|
||||
latestMessage.wasBroadcast = true;
|
||||
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
|
||||
for (const StoredMessage &m : messageStore.getLiveMessages()) {
|
||||
if (m.type == MessageType::BROADCAST) {
|
||||
latestMessage.broadcast = m;
|
||||
lastBroadcastPos = pos;
|
||||
} else if (m.type == MessageType::DM_TO_US) {
|
||||
latestMessage.dm = m;
|
||||
lastDMPos = pos;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
latestMessage.wasBroadcast = (lastBroadcastPos > lastDMPos);
|
||||
}
|
||||
|
||||
// Save the InkHUD settings to flash
|
||||
@@ -42,15 +42,10 @@ void InkHUD::Persistence::saveSettings()
|
||||
FlashData<Settings>::save(&settings, "settings");
|
||||
}
|
||||
|
||||
// Save latestMessage data to flash
|
||||
// Persist all messages via the global messageStore.
|
||||
void InkHUD::Persistence::saveLatestMessage()
|
||||
{
|
||||
// Number of strings saved determines whether last message was broadcast or dm
|
||||
MessageStore store("latest");
|
||||
store.messages.push_back(latestMessage.dm);
|
||||
if (latestMessage.wasBroadcast)
|
||||
store.messages.push_back(latestMessage.broadcast);
|
||||
store.saveToFlash();
|
||||
messageStore.saveToFlash();
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -15,7 +15,7 @@ The save / load mechanism is a shared NicheGraphics feature.
|
||||
#include "configuration.h"
|
||||
|
||||
#include "./InkHUD.h"
|
||||
#include "graphics/niche/InkHUD/MessageStore.h"
|
||||
#include "MessageStore.h"
|
||||
#include "graphics/niche/Utils/FlashData.h"
|
||||
|
||||
namespace NicheGraphics::InkHUD
|
||||
@@ -120,12 +120,12 @@ class Persistence
|
||||
};
|
||||
|
||||
// Most recently received text message
|
||||
// Value is updated by InkHUD::WindowManager, as a courtesy to applets
|
||||
// InkHUD keeps its own latest-message cache for applets.
|
||||
// Value is updated by InkHUD::Events, as a courtesy to applets.
|
||||
// Populated at boot from the global messageStore, then updated live on receive.
|
||||
struct LatestMessage {
|
||||
MessageStore::Message broadcast; // Most recent message received broadcast
|
||||
MessageStore::Message dm; // Most recent received DM
|
||||
bool wasBroadcast; // True if most recent broadcast is newer than most recent dm
|
||||
StoredMessage broadcast; // Most recent broadcast message received
|
||||
StoredMessage dm; // Most recent DM received
|
||||
bool wasBroadcast; // True if most recent broadcast is newer than most recent dm
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
|
||||
@@ -422,9 +422,11 @@ Stores InkHUD data in flash
|
||||
- settings
|
||||
- most recent text message received (both for broadcast and DM)
|
||||
|
||||
In rare cases, applets may store their own specific data separately (e.g. `ThreadedMessageApplet`)
|
||||
Message history (used by `ThreadedMessageApplet`) is stored by the firmware-wide `MessageStore` (`src/MessageStore.h`), not by `Persistence` directly.
|
||||
|
||||
Data saved only on shutdown / reboot. Not saved if power is removed unexpectedly.
|
||||
Settings are saved only on shutdown / reboot. Not saved if power is removed unexpectedly.
|
||||
|
||||
Message history is saved periodically (every 2 hours by default), as well as on shutdown / reboot.
|
||||
|
||||
---
|
||||
|
||||
@@ -466,18 +468,21 @@ Collected here, so various user applets don't all have to store their own copy o
|
||||
|
||||
We keep this separate latest-message cache for this purpose, because:
|
||||
|
||||
- it is cleared by an outgoing text message
|
||||
- we want to store both a recent broadcast and a recent DM
|
||||
- we want to expose both the most recent broadcast and most recent DM independently
|
||||
- applets like `DMApplet` and `NotificationApplet` need quick access without scanning the full message history
|
||||
|
||||
#### How messages reach the store
|
||||
|
||||
Broadcasts and DMs take different paths into `messageStore`:
|
||||
|
||||
- **Broadcasts** — `ThreadedMessageApplet::handleReceived()` calls `messageStore.addFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`.
|
||||
- **DMs** — `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.addFromPacket()` directly and stores the result in `latestMessage.dm`.
|
||||
|
||||
#### Saving / Loading
|
||||
|
||||
_A bit of a hack.._
|
||||
Stored to flash using `InkHUD::MessageStore`, which is really intended for storing a thread of messages (see `ThreadedMessageApplet`). Used because it stores strings more efficiently than `FlashData.h`.
|
||||
The `LatestMessage` cache is not persisted to its own file. On boot, `InkHUD::begin()` calls `messageStore.loadFromFlash()` first, then `Persistence::loadLatestMessage()` rebuilds the cache by scanning the loaded messages for the most recent broadcast and DM.
|
||||
|
||||
The hack is:
|
||||
|
||||
- If most recent message was a DM, we only store the DM.
|
||||
- If most recent message was a broadcast, we store both a DM and a broadcast. The DM may be 0-length string.
|
||||
Text is stored in the firmware-wide shared text pool. Use `MessageStore::getText(msg)` to retrieve it from a `StoredMessage`.
|
||||
|
||||
---
|
||||
|
||||
@@ -582,13 +587,17 @@ Handles events which impact the InkHUD system generally (e.g. shutdown, button p
|
||||
|
||||
Applets themselves do also listen separately for various events, but for the purpose of gathering information which they would like to display.
|
||||
|
||||
#### Text Messages
|
||||
|
||||
`Events::onReceiveTextMessage()` is the central handler for all incoming text messages. It updates the `LatestMessage` cache and, for DMs, also adds the message to `messageStore` (since `ThreadedMessageApplet` only handles broadcasts). See `Persistence::LatestMessage` for details on how the two message types are stored.
|
||||
|
||||
#### Buttons
|
||||
|
||||
Button input is sometimes handled by a system applet. `InkHUD::Events` determines whether the button should be handled by a specific system applet, or should instead trigger a default behavior
|
||||
|
||||
#### Factory Reset
|
||||
|
||||
The Events class handles the admin messages(s) which trigger factory reset. We set `Events::eraseOnReboot = true`, which causes `Events::onReboot` to erase the contents of InkHUD's data directory. We do this because some applets (e.g. ThreadedMessageApplet) save their own data to flash, so if we erased earlier, that data would get re-written during reboot.
|
||||
The Events class handles the admin message(s) which trigger factory reset. We set `Events::eraseOnReboot = true`, which causes `Events::onReboot` to erase the contents of InkHUD's data directory (`/NicheGraphics/`) and also call `messageStore.clearAllMessages()` to wipe the firmware-wide message store (`/Messages_default.msgs`). Both are cleared during reboot rather than earlier, to avoid data being re-written by applets still running before shutdown.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+168
-3
@@ -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"
|
||||
@@ -136,6 +149,10 @@ void printPartitionTable()
|
||||
#include "motion/AccelerometerThread.h"
|
||||
AccelerometerThread *accelerometerThread = nullptr;
|
||||
#endif
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_MAGNETOMETER
|
||||
#include "motion/MagnetometerThread.h"
|
||||
MagnetometerThread *magnetometerThread = nullptr;
|
||||
#endif
|
||||
|
||||
#ifdef HAS_I2S
|
||||
#include "AudioThread.h"
|
||||
@@ -206,6 +223,8 @@ bool osk_found = false;
|
||||
ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE;
|
||||
// The I2C address of the Accelerometer (if found)
|
||||
ScanI2C::DeviceAddress accelerometer_found = ScanI2C::ADDRESS_NONE;
|
||||
// The I2C address of the Magnetometer (if found)
|
||||
ScanI2C::DeviceAddress magnetometer_found = ScanI2C::ADDRESS_NONE;
|
||||
// The I2C address of the RGB LED (if found)
|
||||
ScanI2C::FoundDevice rgb_found = ScanI2C::FoundDevice(ScanI2C::DeviceType::NONE, ScanI2C::ADDRESS_NONE);
|
||||
/// The I2C address of our Air Quality Indicator (if found)
|
||||
@@ -373,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
|
||||
@@ -403,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");
|
||||
@@ -431,6 +463,11 @@ void setup()
|
||||
digitalWrite(VEXT_ENABLE, VEXT_ON_VALUE); // turn on the display power
|
||||
#endif
|
||||
|
||||
#if defined(PIN_SENSOR_EN)
|
||||
pinMode(PIN_SENSOR_EN, OUTPUT);
|
||||
digitalWrite(PIN_SENSOR_EN, PIN_SENSOR_EN_ACTIVE); // turn on sensor power
|
||||
#endif
|
||||
|
||||
#if defined(BIAS_T_ENABLE)
|
||||
pinMode(BIAS_T_ENABLE, OUTPUT);
|
||||
digitalWrite(BIAS_T_ENABLE, BIAS_T_VALUE); // turn on 5V for GPS Antenna
|
||||
@@ -470,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);
|
||||
@@ -671,6 +740,11 @@ void setup()
|
||||
accelerometer_found = acc_info.type != ScanI2C::DeviceType::NONE ? acc_info.address : accelerometer_found;
|
||||
LOG_DEBUG("acc_info = %i", acc_info.type);
|
||||
#endif
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_MAGNETOMETER
|
||||
auto mag_info = i2cScanner->firstMagnetometer();
|
||||
magnetometer_found = mag_info.type != ScanI2C::DeviceType::NONE ? mag_info.address : magnetometer_found;
|
||||
LOG_DEBUG("mag_info = %i", mag_info.type);
|
||||
#endif
|
||||
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA260, meshtastic_TelemetrySensorType_INA260);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA226, meshtastic_TelemetrySensorType_INA226);
|
||||
@@ -683,6 +757,8 @@ void setup()
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMI8658, meshtastic_TelemetrySensorType_QMI8658);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMC5883L, meshtastic_TelemetrySensorType_QMC5883L);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::HMC5883L, meshtastic_TelemetrySensorType_QMC5883L);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MMC5983MA, meshtastic_TelemetrySensorType_MMC5983MA);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::ICM42607P, meshtastic_TelemetrySensorType_ICM42607P);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MLX90614, meshtastic_TelemetrySensorType_MLX90614);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::ICM20948, meshtastic_TelemetrySensorType_ICM20948);
|
||||
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MAX30102, meshtastic_TelemetrySensorType_MAX30102);
|
||||
@@ -766,6 +842,11 @@ void setup()
|
||||
accelerometerThread = new AccelerometerThread(acc_info.type);
|
||||
}
|
||||
#endif
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_MAGNETOMETER
|
||||
if (mag_info.type != ScanI2C::DeviceType::NONE) {
|
||||
magnetometerThread = new MagnetometerThread(mag_info.type);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(HAS_NEOPIXEL) || defined(UNPHONE) || defined(RGBLED_RED)
|
||||
ambientLightingThread = new AmbientLightingThread(ScanI2C::DeviceType::NONE);
|
||||
@@ -1054,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;
|
||||
@@ -1105,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
|
||||
|
||||
@@ -1138,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
|
||||
@@ -1220,7 +1385,7 @@ void loop()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
#if HAS_SCREEN && ENABLE_MESSAGE_PERSISTENCE
|
||||
#if (HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)) && ENABLE_MESSAGE_PERSISTENCE
|
||||
messageStoreAutosaveTick();
|
||||
#endif
|
||||
long delayMsec = mainController.runOrDelay();
|
||||
|
||||
+18
@@ -39,6 +39,7 @@ extern bool kb_found;
|
||||
extern bool osk_found;
|
||||
extern ScanI2C::DeviceAddress rtc_found;
|
||||
extern ScanI2C::DeviceAddress accelerometer_found;
|
||||
extern ScanI2C::DeviceAddress magnetometer_found;
|
||||
extern ScanI2C::FoundDevice rgb_found;
|
||||
extern ScanI2C::DeviceAddress aqi_found;
|
||||
|
||||
@@ -73,6 +74,10 @@ extern graphics::Screen *screen;
|
||||
#include "motion/AccelerometerThread.h"
|
||||
extern AccelerometerThread *accelerometerThread;
|
||||
#endif
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_MAGNETOMETER
|
||||
#include "motion/MagnetometerThread.h"
|
||||
extern MagnetometerThread *magnetometerThread;
|
||||
#endif
|
||||
|
||||
extern bool isVibrating;
|
||||
|
||||
@@ -87,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
|
||||
|
||||
@@ -60,6 +60,26 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d
|
||||
return base * coef;
|
||||
}
|
||||
|
||||
uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes,
|
||||
TrafficType type)
|
||||
{
|
||||
uint32_t baseMs = getConfiguredOrDefaultMsScaled(configured, defaultValue, numOnlineNodes);
|
||||
|
||||
if (!myRegion || !myRegion->profile)
|
||||
return baseMs;
|
||||
|
||||
int8_t throttle =
|
||||
(type == TrafficType::POSITION) ? myRegion->profile->positionThrottle : myRegion->profile->telemetryThrottle;
|
||||
|
||||
// throttle <= 0 means unset; 1 is the neutral multiplier — skip the multiply for performance
|
||||
if (throttle <= 1)
|
||||
return baseMs;
|
||||
|
||||
constexpr uint32_t MAX_MS = static_cast<uint32_t>(INT32_MAX);
|
||||
uint64_t result = static_cast<uint64_t>(baseMs) * static_cast<uint64_t>(throttle);
|
||||
return result >= static_cast<uint64_t>(MAX_MS) ? MAX_MS : static_cast<uint32_t>(result);
|
||||
}
|
||||
|
||||
uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue)
|
||||
{
|
||||
// If zero, intervals should be coalesced later by getConfiguredOrDefault... methods
|
||||
|
||||
@@ -31,10 +31,18 @@
|
||||
#define min_neighbor_info_broadcast_secs 4 * 60 * 60
|
||||
#define default_map_publish_interval_secs 60 * 60
|
||||
|
||||
enum class TrafficType { POSITION, TELEMETRY };
|
||||
|
||||
// Traffic management defaults
|
||||
#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
|
||||
@@ -64,6 +72,8 @@ class Default
|
||||
// Note: numOnlineNodes uses uint32_t to match the public API and allow flexibility,
|
||||
// even though internal node counts use uint16_t (max 65535 nodes)
|
||||
static uint32_t getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes);
|
||||
static uint32_t getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes,
|
||||
TrafficType type);
|
||||
static uint8_t getConfiguredOrDefaultHopLimit(uint8_t configured);
|
||||
static uint32_t getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue);
|
||||
|
||||
|
||||
@@ -368,4 +368,10 @@ template <typename T> bool LR11x0Interface<T>::sleep()
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename T> int16_t LR11x0Interface<T>::getCurrentRSSI()
|
||||
{
|
||||
float rssi = lora.getRSSI(false, true);
|
||||
return (int16_t)round(rssi);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -37,6 +37,8 @@ template <class T> class LR11x0Interface : public RadioLibInterface
|
||||
*/
|
||||
T lora;
|
||||
|
||||
int16_t getCurrentRSSI() override;
|
||||
|
||||
/**
|
||||
* Glue functions called from ISR land
|
||||
*/
|
||||
|
||||
@@ -136,17 +136,6 @@ template <typename T> bool LR20x0Interface<T>::init()
|
||||
if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED)
|
||||
return false;
|
||||
|
||||
// Some basic info about the module's explicit firmware version - no other info available
|
||||
// Currently requires radiolib godmode
|
||||
|
||||
#if RADIOLIB_GODMODE
|
||||
uint8_t fwMajor = 0;
|
||||
uint8_t fwMinor = 0;
|
||||
int versionRes = lora.getVersion(&fwMajor, &fwMinor);
|
||||
if (versionRes == RADIOLIB_ERR_NONE)
|
||||
LOG_DEBUG("LR20x0 FW %d.%d", fwMajor, fwMinor);
|
||||
#endif
|
||||
|
||||
LOG_INFO("Frequency set to %f", getFreq());
|
||||
LOG_INFO("Bandwidth set to %f", bw);
|
||||
LOG_INFO("Power output set to %d", power);
|
||||
@@ -154,17 +143,6 @@ template <typename T> bool LR20x0Interface<T>::init()
|
||||
if (res == RADIOLIB_ERR_NONE)
|
||||
res = lora.setCRC(2);
|
||||
|
||||
// Standard DCDC ramp timing from RadioLib workarounds (register 0x00F20024)
|
||||
// Currently requires radiolib godmode
|
||||
#if RADIOLIB_GODMODE
|
||||
if (res == RADIOLIB_ERR_NONE) {
|
||||
uint8_t rampTimes[4] = {15, 15, 15, 15}; // Standard case for all conditions
|
||||
res = lora.setRegMode(RADIOLIB_LR2021_REG_MODE_SIMO_NORMAL, rampTimes);
|
||||
if (res != RADIOLIB_ERR_NONE)
|
||||
LOG_WARN("LR2021 setRegMode failed: %d", res);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef LR2021_DIO_AS_RF_SWITCH
|
||||
bool dioAsRfSwitch = true;
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
@@ -396,4 +374,10 @@ template <typename T> bool LR20x0Interface<T>::sleep()
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename T> int16_t LR20x0Interface<T>::getCurrentRSSI()
|
||||
{
|
||||
float rssi = lora.getRSSI(false, true);
|
||||
return (int16_t)round(rssi);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -37,6 +37,8 @@ template <class T> class LR20x0Interface : public RadioLibInterface
|
||||
*/
|
||||
T lora;
|
||||
|
||||
int16_t getCurrentRSSI() override;
|
||||
|
||||
/**
|
||||
* Glue functions called from ISR land
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user