Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80fcb061e6 | ||
|
|
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 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 -c \"import json,sys,subprocess,shutil,os; f=json.load(sys.stdin).get('tool_input',{}).get('file_path',''); t=shutil.which('trunk') or os.path.expanduser('~/.cache/trunk/launcher/trunk'); f and os.path.exists(t) and subprocess.run([t,'fmt','--force',f],stderr=subprocess.DEVNULL)\" 2>/dev/null || true",
|
||||
"statusMessage": "Formatting..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -609,20 +609,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,160 @@
|
||||
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:
|
||||
- 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 number (run.pull_requests is empty for fork PRs)
|
||||
let prNumber = run.pull_requests?.[0]?.number;
|
||||
if (!prNumber) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner, repo, commit_sha: run.head_sha,
|
||||
});
|
||||
prNumber = (prs.find((pr) => pr.head.sha === run.head_sha) ?? prs[0])?.number;
|
||||
}
|
||||
if (!prNumber) {
|
||||
core.info('No pull request associated with this run; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
|
||||
// Only comment on PRs authored by members of the organization.
|
||||
// author_association MEMBER is computed by GitHub and reflects org
|
||||
// membership (including concealed members); OWNER covers a repo owner.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not an org member; skipping.`);
|
||||
return;
|
||||
}
|
||||
if (pr.state !== 'open') {
|
||||
core.info('Pull request is not open; skipping.');
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== run.head_sha) {
|
||||
core.info('Run is for an outdated commit; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Require at least one per-arch firmware artifact from gather-artifacts
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner, repo, run_id: run.id, per_page: 100,
|
||||
});
|
||||
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
|
||||
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
|
||||
if (archArtifacts.length === 0) {
|
||||
core.info('No per-arch firmware artifacts found; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = archRe.exec(archArtifacts[0].name)[2];
|
||||
const expiresAt = archArtifacts[0].expires_at
|
||||
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
|
||||
: null;
|
||||
|
||||
// Per-board deep links from manifest-{platform}-{board}-{version} artifacts
|
||||
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const boardRe = new RegExp(`^manifest-([a-z0-9]+)-(.+)-${escapedVersion}$`);
|
||||
let boards = artifacts
|
||||
.map((a) => boardRe.exec(a.name))
|
||||
.filter(Boolean)
|
||||
.map((m) => ({ platform: m[1], board: m[2] }))
|
||||
.sort((a, b) => a.board.localeCompare(b.board));
|
||||
|
||||
// Limit the list to devices the web flasher actively supports — the same
|
||||
// activelySupported / non-portduino filter the flasher applies to its own
|
||||
// device list — matching variant envs (-tft/-inkhud) to their base target.
|
||||
// On a fetch failure, fall back to listing all built boards.
|
||||
try {
|
||||
const hw = await (await fetch('https://api.meshtastic.org/resource/deviceHardware')).json();
|
||||
const supported = new Set(
|
||||
hw.filter((d) => d.activelySupported && !String(d.architecture || '').startsWith('portduino'))
|
||||
.map((d) => d.platformioTarget),
|
||||
);
|
||||
const baseTarget = (b) => b.replace(/-(tft|inkhud)$/, '');
|
||||
boards = boards.filter((b) => supported.has(b.board) || supported.has(baseTarget(b.board)));
|
||||
} catch (e) {
|
||||
core.warning(`Could not fetch device hardware list; listing all built boards. ${e.message}`);
|
||||
}
|
||||
|
||||
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
|
||||
const boardLines = boards
|
||||
.map((b) => `| [\`${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>`,
|
||||
'',
|
||||
'| 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 });
|
||||
}
|
||||
@@ -245,47 +245,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-")) | .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/${ARTIFACT_NAME}/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-")) | .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/${ARTIFACT_NAME}/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'.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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,138 @@
|
||||
#!/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. All five are used by every nrf52840
|
||||
# Meshtastic build; if a board deliberately stops using one, edit this tuple 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
|
||||
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
|
||||
"POWER_CLOCK_IRQHandler", # HF/LF clock + power (HFCLK start for radio & SoftDevice)
|
||||
)
|
||||
|
||||
_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]
|
||||
dropped = [h for h in _REQUIRED_STRONG 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_STRONG)
|
||||
)
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -208,16 +208,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: 519a0c7c9c...7916a0ce81
@@ -51,6 +51,12 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
||||
case PRESET(NARROW_SLOW):
|
||||
return useShortName ? "NarS" : "NarrowSlow";
|
||||
break;
|
||||
case PRESET(TINY_FAST):
|
||||
return useShortName ? "TinyF" : "TinyFast";
|
||||
break;
|
||||
case PRESET(TINY_SLOW):
|
||||
return useShortName ? "TinyS" : "TinySlow";
|
||||
break;
|
||||
default:
|
||||
return useShortName ? "Custom" : "Invalid";
|
||||
break;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+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];
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+299
-12
@@ -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"
|
||||
|
||||
@@ -71,6 +74,67 @@ 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";
|
||||
|
||||
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> 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 +556,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_STM32WL)
|
||||
_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";
|
||||
_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();
|
||||
cachedProbeFailedThisBoot = true;
|
||||
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.
|
||||
@@ -503,25 +762,46 @@ bool GPS::setup()
|
||||
{
|
||||
if (!didSerialInit) {
|
||||
int msglen = 0;
|
||||
if (cachedProbeFailedThisBoot) {
|
||||
// If cached verification failed, suppress further probing until
|
||||
// reboot.
|
||||
didSerialInit = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
if (probeTries < GPS_PROBETRIES) {
|
||||
if (!hasProbeCache && !triedProbeCache) {
|
||||
(void)loadProbeCache();
|
||||
}
|
||||
|
||||
if (hasProbeCache && !triedProbeCache) {
|
||||
triedProbeCache = true;
|
||||
if (!verifyCachedProbePresence()) {
|
||||
// Cache was stale and got wiped; skip scanning this boot
|
||||
// and let next boot do a full probe.
|
||||
didSerialInit = true;
|
||||
return true;
|
||||
}
|
||||
} else if (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 (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 +809,7 @@ bool GPS::setup()
|
||||
|
||||
if (gnssModel != GNSS_MODEL_UNKNOWN) {
|
||||
setConnected();
|
||||
(void)saveProbeCache();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -1102,6 +1383,12 @@ int32_t GPS::runOnce()
|
||||
if (!setup())
|
||||
return currentDelay; // Setup failed, re-run in two seconds
|
||||
|
||||
if (cachedProbeFailedThisBoot || gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
LOG_WARN("GPS not detected at cached settings; 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();
|
||||
|
||||
@@ -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,12 @@ 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;
|
||||
// Latched when cached presence check fails
|
||||
bool cachedProbeFailedThisBoot = false;
|
||||
|
||||
/**
|
||||
* hasValidLocation - indicates that the position variables contain a complete
|
||||
|
||||
@@ -50,6 +50,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"
|
||||
@@ -655,6 +656,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)
|
||||
@@ -1416,6 +1421,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 +1462,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");
|
||||
|
||||
+11
-1
@@ -12,7 +12,7 @@
|
||||
#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 };
|
||||
|
||||
struct BannerOverlayOptions {
|
||||
const char *message;
|
||||
@@ -623,6 +623,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 +743,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;
|
||||
|
||||
@@ -183,9 +183,13 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
static float segmentHeight = SEGMENT_HEIGHT * 0.75f;
|
||||
|
||||
if (!scaleInitialized) {
|
||||
#ifdef DISPLAY_FORCE_SMALL_FONTS
|
||||
float screenwidth_target_ratio = 0.70f; // Target 70% of display width (adjustable)
|
||||
#else
|
||||
float screenwidth_target_ratio = 0.80f; // Target 80% of display width (adjustable)
|
||||
float max_scale = 3.5f; // Safety limit to avoid runaway scaling
|
||||
float step = 0.05f; // Step increment per iteration
|
||||
#endif
|
||||
float max_scale = 3.5f; // Safety limit to avoid runaway scaling
|
||||
float step = 0.05f; // Step increment per iteration
|
||||
|
||||
float target_width = display->getWidth() * screenwidth_target_ratio;
|
||||
float target_height =
|
||||
|
||||
@@ -126,6 +126,7 @@ void launchReplyForMessage(const StoredMessage &message, bool freetext)
|
||||
|
||||
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
|
||||
uint32_t menuHandler::pickedNodeNum = 0;
|
||||
meshtastic_Config_LoRaConfig_RegionCode menuHandler::pendingRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||
bool test_enabled = false;
|
||||
uint8_t test_count = 0;
|
||||
|
||||
@@ -174,6 +175,36 @@ 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
|
||||
|
||||
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) {
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
changes |= SEGMENT_MODULECONFIG;
|
||||
}
|
||||
service->reloadConfig(changes);
|
||||
}
|
||||
|
||||
void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
{
|
||||
static const LoraRegionOption regionOptions[] = {
|
||||
@@ -206,6 +237,10 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
{"KZ_863", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KZ_863},
|
||||
{"NP_865", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_NP_865},
|
||||
{"BR_902", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_BR_902},
|
||||
{"ITU1_2M (144-146)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M},
|
||||
{"ITU2_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M},
|
||||
{"ITU3_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M},
|
||||
{"ITU2_125CM (220-225)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM},
|
||||
|
||||
};
|
||||
|
||||
@@ -238,27 +273,20 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
return;
|
||||
}
|
||||
|
||||
config.lora.region = selectedRegion;
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
|
||||
if (hamMode) {
|
||||
LOG_INFO("User chose an amateur radio mode region");
|
||||
pendingRegion = selectedRegion;
|
||||
menuQueue = HamModeConfirm;
|
||||
screen->runNow();
|
||||
} else if (owner.is_licensed) {
|
||||
LOG_INFO("Licensed user chose a non-ham region; prompting to revert licensed mode");
|
||||
pendingRegion = selectedRegion;
|
||||
menuQueue = LicensedToNormalConfirm;
|
||||
screen->runNow();
|
||||
} else {
|
||||
applyLoraRegion(selectedRegion, false);
|
||||
}
|
||||
#endif
|
||||
config.lora.tx_enabled = true;
|
||||
initRegion();
|
||||
if (getEffectiveDutyCycle() < 100) {
|
||||
config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
||||
}
|
||||
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
// Default broker is in use, so subscribe to the appropriate MQTT root topic for this region
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
changes |= SEGMENT_MODULECONFIG;
|
||||
}
|
||||
|
||||
service->reloadConfig(changes);
|
||||
});
|
||||
|
||||
bannerOptions.durationMs = duration;
|
||||
@@ -275,6 +303,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"};
|
||||
@@ -2818,6 +2878,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] = {};
|
||||
@@ -259,6 +268,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 +357,105 @@ void NotificationRenderer::drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiS
|
||||
drawNotificationBox(display, state, linePointers, totalLines, 0);
|
||||
}
|
||||
|
||||
void NotificationRenderer::drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||||
{
|
||||
const char *lineStarts[MAX_LINES + 1] = {0};
|
||||
uint16_t lineCount = 0;
|
||||
|
||||
// Parse lines
|
||||
char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
|
||||
lineStarts[lineCount] = alertBannerMessage;
|
||||
|
||||
// Find lines
|
||||
while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
|
||||
lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n');
|
||||
if (lineStarts[lineCount + 1][0] == '\n')
|
||||
lineStarts[lineCount + 1] += 1;
|
||||
lineCount++;
|
||||
}
|
||||
// modulo to extract
|
||||
uint8_t this_digit = (currentNumber % (pow_of_16(numDigits - curSelected))) / (pow_of_16(numDigits - curSelected - 1));
|
||||
// Handle input
|
||||
if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS ||
|
||||
inEvent.inputEvent == INPUT_BROKER_UP_LONG) {
|
||||
if (this_digit == 15) {
|
||||
currentNumber -= 15 * (pow_of_16(numDigits - curSelected - 1));
|
||||
} else {
|
||||
currentNumber += (pow_of_16(numDigits - curSelected - 1));
|
||||
}
|
||||
} else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS ||
|
||||
inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) {
|
||||
if (this_digit == 0) {
|
||||
currentNumber += 15 * (pow_of_16(numDigits - curSelected - 1));
|
||||
} else {
|
||||
currentNumber -= (pow_of_16(numDigits - curSelected - 1));
|
||||
}
|
||||
} else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) {
|
||||
if (inEvent.kbchar > 47 && inEvent.kbchar < 58) { // have a digit
|
||||
currentNumber -= this_digit * (pow_of_16(numDigits - curSelected - 1));
|
||||
currentNumber += (inEvent.kbchar - 48) * (pow_of_16(numDigits - curSelected - 1));
|
||||
curSelected++;
|
||||
}
|
||||
} else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) {
|
||||
curSelected++;
|
||||
} else if (inEvent.inputEvent == INPUT_BROKER_LEFT) {
|
||||
curSelected--;
|
||||
} else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) &&
|
||||
alertBannerUntil != 0) {
|
||||
resetBanner();
|
||||
return;
|
||||
}
|
||||
if (curSelected == static_cast<int8_t>(numDigits)) {
|
||||
alertBannerCallback(currentNumber);
|
||||
resetBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
inEvent.inputEvent = INPUT_BROKER_NONE;
|
||||
if (alertBannerMessage[0] == '\0')
|
||||
return;
|
||||
|
||||
uint16_t totalLines = lineCount + 2;
|
||||
const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation
|
||||
|
||||
// copy the linestarts to display to the linePointers holder
|
||||
for (uint16_t i = 0; i < lineCount; i++) {
|
||||
linePointers[i] = lineStarts[i];
|
||||
}
|
||||
std::string digits = " ";
|
||||
std::string arrowPointer = " ";
|
||||
for (uint16_t i = 0; i < numDigits; i++) {
|
||||
// Modulo minus modulo to return just the current number
|
||||
uint8_t digitValue = (currentNumber % (pow_of_16(numDigits - i))) / (pow_of_16(numDigits - i - 1));
|
||||
if (digitValue < 10) {
|
||||
digits += std::to_string(digitValue) + " ";
|
||||
} else if (digitValue == 10) {
|
||||
digits += "A ";
|
||||
} else if (digitValue == 11) {
|
||||
digits += "B ";
|
||||
} else if (digitValue == 12) {
|
||||
digits += "C ";
|
||||
} else if (digitValue == 13) {
|
||||
digits += "D ";
|
||||
} else if (digitValue == 14) {
|
||||
digits += "E ";
|
||||
} else if (digitValue == 15) {
|
||||
digits += "F ";
|
||||
}
|
||||
|
||||
if (curSelected == i) {
|
||||
arrowPointer += "^ ";
|
||||
} else {
|
||||
arrowPointer += "_ ";
|
||||
}
|
||||
}
|
||||
|
||||
linePointers[lineCount++] = digits.c_str();
|
||||
linePointers[lineCount++] = arrowPointer.c_str();
|
||||
|
||||
drawNotificationBox(display, state, linePointers, totalLines, 0);
|
||||
}
|
||||
|
||||
void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||||
{
|
||||
static uint32_t selectedNodenum = 0;
|
||||
|
||||
@@ -42,6 +42,7 @@ class NotificationRenderer
|
||||
static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1],
|
||||
|
||||
@@ -79,10 +79,12 @@ static inline void transformNeedlePoint(float localX, float localY, float sinHea
|
||||
outY = static_cast<int16_t>(y);
|
||||
}
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
static float getCompassRingAngleOffset(float heading)
|
||||
{
|
||||
return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f;
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline StandardCompassNeedlePoints computeStandardCompassNeedlePoints(int16_t compassX, int16_t compassY,
|
||||
uint16_t compassDiam, float headingRadian,
|
||||
@@ -1142,11 +1144,16 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
bool origBold = config.display.heading_bold;
|
||||
config.display.heading_bold = false;
|
||||
|
||||
// Display Region and Channel Utilization
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
if (!config.lora.tx_enabled) {
|
||||
const char *txdisabled = "Transmit Disabled";
|
||||
display->drawString(x, getTextPositions(display)[line], txdisabled);
|
||||
} else {
|
||||
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
// Display Region and Channel Utilization
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
} else {
|
||||
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
}
|
||||
}
|
||||
char uptimeStr[32] = "";
|
||||
if (currentResolution != ScreenResolution::UltraLow) {
|
||||
|
||||
@@ -6,15 +6,12 @@
|
||||
FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
|
||||
|
||||
Both formats share the same pixel layout and polarity (1 = white, 0 = black).
|
||||
The InkHUD safe-area buffer (944×523) is copied into the centre of the physical
|
||||
The InkHUD safe-area buffer (928×508) is copied into the centre of the physical
|
||||
960×540 FastEPD buffer so content clears the panel's inactive edge border.
|
||||
See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants.
|
||||
|
||||
*/
|
||||
|
||||
// Ruler diagnostic — uncomment to draw calibration lines at each physical edge.
|
||||
// #define EINK_EDGE_LINES
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
#ifdef T5_S3_EPAPER_PRO
|
||||
|
||||
@@ -212,35 +209,6 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
|
||||
memcpy(dstRow, srcRow, srcRowBytes);
|
||||
}
|
||||
|
||||
#ifdef EINK_EDGE_LINES
|
||||
// Draw a 1px black box at the exact boundary of the safe area within the
|
||||
// physical buffer. If the margins are correct, all 4 lines should be
|
||||
// fully visible and right at the edge of the usable display area.
|
||||
|
||||
auto setPixelBlack = [&](uint32_t col, uint32_t row) { cur[row * dstRowBytes + col / 8] &= ~(0x80 >> (col % 8)); };
|
||||
|
||||
const uint32_t safeX = H_OFFSET_BYTES * 8;
|
||||
const uint32_t safeY = V_OFFSET_TOP;
|
||||
const uint32_t safeW = DISPLAY_WIDTH;
|
||||
const uint32_t safeH = DISPLAY_HEIGHT;
|
||||
|
||||
// Top edge: horizontal line at safeY
|
||||
for (uint32_t col = safeX; col < safeX + safeW; col++)
|
||||
setPixelBlack(col, safeY);
|
||||
|
||||
// Bottom edge: horizontal line at safeY + safeH - 1
|
||||
for (uint32_t col = safeX; col < safeX + safeW; col++)
|
||||
setPixelBlack(col, safeY + safeH - 1);
|
||||
|
||||
// Left edge: vertical line at safeX
|
||||
for (uint32_t row = safeY; row < safeY + safeH; row++)
|
||||
setPixelBlack(safeX, row);
|
||||
|
||||
// Right edge: vertical line at safeX + safeW - 1
|
||||
for (uint32_t row = safeY; row < safeY + safeH; row++)
|
||||
setPixelBlack(safeX + safeW - 1, row);
|
||||
#endif
|
||||
|
||||
if (type == FULL) {
|
||||
epaper->fullUpdate(CLEAR_SLOW, false);
|
||||
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
V_OFFSET_TOP and V_OFFSET_BOTTOM (vertical, pixel rows) to position it.
|
||||
|
||||
Changing these constants shifts content inward from each physical edge:
|
||||
H_OFFSET_BYTES = 1 → 8px left margin, 8px right margin (960 – 8 – 8 = 944)
|
||||
V_OFFSET_TOP = 9 → 9px top margin (asymmetric: top ≠ bottom)
|
||||
V_OFFSET_BOTTOM = 8 → 8px bottom margin (540 – 9 – 8 = 523)
|
||||
H_OFFSET_BYTES = 2 → 16px left margin, 16px right margin (960 – 16 – 16 = 928)
|
||||
V_OFFSET_TOP = 16 → 16px top margin
|
||||
V_OFFSET_BOTTOM = 16 → 16px bottom margin (540 – 16 – 16 = 508)
|
||||
|
||||
*/
|
||||
|
||||
@@ -61,13 +61,13 @@ class ED047TC1 : public EInk
|
||||
//
|
||||
// Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible.
|
||||
|
||||
static constexpr uint16_t DISPLAY_WIDTH = 944; // 960 − H_OFFSET_BYTES×8 − right_margin (8+8 = 16px)
|
||||
static constexpr uint16_t DISPLAY_HEIGHT = 523; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (9+8 = 17px)
|
||||
static constexpr uint16_t DISPLAY_WIDTH = 928; // 960 − H_OFFSET_BYTES×8 − right_margin (16+16 = 32px)
|
||||
static constexpr uint16_t DISPLAY_HEIGHT = 508; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (16+16 = 32px)
|
||||
|
||||
static constexpr uint8_t H_OFFSET_BYTES = 1; // visual TOP : 8px physical left margin
|
||||
// visual BOTTOM: 960−8−944=8px physical right margin
|
||||
static constexpr uint8_t V_OFFSET_TOP = 9; // visual RIGHT : CONFIRMED OK
|
||||
static constexpr uint8_t V_OFFSET_BOTTOM = 8; // visual LEFT : 8px physical bottom margin
|
||||
static constexpr uint8_t H_OFFSET_BYTES = 2; // visual TOP : 16px physical left margin
|
||||
// visual BOTTOM: 960−16−928=16px physical right margin
|
||||
static constexpr uint8_t V_OFFSET_TOP = 16; // visual RIGHT : 16px physical top margin
|
||||
static constexpr uint8_t V_OFFSET_BOTTOM = 16; // visual LEFT : 16px physical bottom margin
|
||||
|
||||
static constexpr UpdateTypes supported = static_cast<UpdateTypes>(FULL | FAST);
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ enum MenuAction {
|
||||
SET_REGION_BR_902,
|
||||
SET_REGION_EU_866,
|
||||
SET_REGION_NARROW_868,
|
||||
SET_REGION_ITU1_2M,
|
||||
SET_REGION_ITU2_2M,
|
||||
SET_REGION_ITU3_2M,
|
||||
SET_REGION_ITU2_125CM,
|
||||
// Device Roles
|
||||
SET_ROLE_CLIENT,
|
||||
SET_ROLE_CLIENT_MUTE,
|
||||
@@ -84,6 +88,8 @@ enum MenuAction {
|
||||
SET_PRESET_LITE_FAST,
|
||||
SET_PRESET_NARROW_SLOW,
|
||||
SET_PRESET_NARROW_FAST,
|
||||
SET_PRESET_TINY_SLOW,
|
||||
SET_PRESET_TINY_FAST,
|
||||
SET_PRESET_FROM_REGION, // Dynamic: preset chosen from region-available list
|
||||
// Timezones
|
||||
SET_TZ_US_HAWAII,
|
||||
|
||||
@@ -784,6 +784,22 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU1_2M:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU2_2M:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU3_2M:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M);
|
||||
break;
|
||||
|
||||
case SET_REGION_ITU2_125CM:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM);
|
||||
break;
|
||||
|
||||
// Roles
|
||||
case SET_ROLE_CLIENT:
|
||||
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT);
|
||||
@@ -834,6 +850,22 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
applyLoRaPreset(PRESET(SHORT_TURBO));
|
||||
break;
|
||||
|
||||
case SET_PRESET_NARROW_SLOW:
|
||||
applyLoRaPreset(PRESET(NARROW_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_NARROW_FAST:
|
||||
applyLoRaPreset(PRESET(NARROW_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_TINY_SLOW:
|
||||
applyLoRaPreset(PRESET(TINY_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_TINY_FAST:
|
||||
applyLoRaPreset(PRESET(TINY_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_FROM_REGION: {
|
||||
// cursor - 1 because index 0 is "Back"
|
||||
const uint8_t index = cursor - 1;
|
||||
@@ -1469,6 +1501,10 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("KZ 863", MenuAction::SET_REGION_KZ_863, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("NP 865", MenuAction::SET_REGION_NP_865, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("BR 902", MenuAction::SET_REGION_BR_902, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU1_2M (144-146)", MenuAction::SET_REGION_ITU1_2M, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU2_2M (144-148)", MenuAction::SET_REGION_ITU2_2M, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU3_2M (144-148)", MenuAction::SET_REGION_ITU3_2M, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ITU2_125CM (220-225)", MenuAction::SET_REGION_ITU2_125CM, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1243,7 +1243,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();
|
||||
|
||||
@@ -37,6 +37,12 @@ enum class TrafficType { POSITION, TELEMETRY };
|
||||
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
|
||||
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
|
||||
|
||||
// Hop scaling defaults
|
||||
#define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count
|
||||
#define default_hop_scaling_max_target_nodes 80 // generous extension ceiling (2 × min)
|
||||
#define default_hop_scaling_min_target_nodes_floor 5 // minimum allowed min_target_nodes
|
||||
#define default_hop_scaling_max_target_nodes_ceiling 512 // maximum allowed max_target_nodes
|
||||
|
||||
#ifdef USERPREFS_RINGTONE_NAG_SECS
|
||||
#define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS
|
||||
#else
|
||||
|
||||
@@ -2,11 +2,58 @@
|
||||
#include "LoRaFEMInterface.h"
|
||||
|
||||
#if defined(ARCH_ESP32)
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/rtc_io.h>
|
||||
#include <esp_sleep.h>
|
||||
#endif
|
||||
|
||||
LoRaFEMInterface loraFEMInterface;
|
||||
|
||||
static void enableFEMPower()
|
||||
{
|
||||
bool wasOff = digitalRead(LORA_PA_POWER) != HIGH;
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
if (wasOff) {
|
||||
delay(5); // This is an arbitrary 5ms for FEM rail power-up.
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(ARCH_ESP32)
|
||||
static void releasePinHold(int pin)
|
||||
{
|
||||
if (pin < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
gpio_num_t gpio = (gpio_num_t)pin;
|
||||
|
||||
#if SOC_RTCIO_HOLD_SUPPORTED
|
||||
if (rtc_gpio_is_valid_gpio(gpio)) {
|
||||
rtc_gpio_hold_dis(gpio);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (GPIO_IS_VALID_OUTPUT_GPIO(gpio)) {
|
||||
gpio_hold_dis(gpio);
|
||||
}
|
||||
}
|
||||
|
||||
static void releaseSleepHolds()
|
||||
{
|
||||
releasePinHold(LORA_PA_POWER);
|
||||
#ifdef HELTEC_V4
|
||||
releasePinHold(LORA_KCT8103L_PA_CSD);
|
||||
releasePinHold(LORA_KCT8103L_PA_CTX);
|
||||
#elif defined(USE_GC1109_PA)
|
||||
releasePinHold(LORA_GC1109_PA_EN);
|
||||
releasePinHold(LORA_GC1109_PA_TX_EN);
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
releasePinHold(LORA_KCT8103L_PA_CSD);
|
||||
releasePinHold(LORA_KCT8103L_PA_CTX);
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void LoRaFEMInterface::init(void)
|
||||
{
|
||||
setLnaCanControl(false); // Default is uncontrollable
|
||||
@@ -21,6 +68,7 @@ void LoRaFEMInterface::init(void)
|
||||
if (digitalRead(LORA_KCT8103L_PA_CSD) == HIGH) {
|
||||
// FEM is KCT8103L
|
||||
fem_type = KCT8103L_PA;
|
||||
LOG_INFO("Detected KCT8103L LoRa FEM");
|
||||
rtc_gpio_hold_dis((gpio_num_t)LORA_KCT8103L_PA_CTX);
|
||||
pinMode(LORA_KCT8103L_PA_CSD, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
@@ -30,6 +78,7 @@ void LoRaFEMInterface::init(void)
|
||||
} else if (digitalRead(LORA_KCT8103L_PA_CSD) == LOW) {
|
||||
// FEM is GC1109
|
||||
fem_type = GC1109_PA;
|
||||
LOG_INFO("Detected GC1109 LoRa FEM");
|
||||
// LORA_GC1109_PA_EN and LORA_KCT8103L_PA_CSD are the same pin and do not need to be repeatedly turned off and held.
|
||||
// rtc_gpio_hold_dis((gpio_num_t)LORA_GC1109_PA_EN);
|
||||
pinMode(LORA_GC1109_PA_EN, OUTPUT);
|
||||
@@ -41,6 +90,7 @@ void LoRaFEMInterface::init(void)
|
||||
}
|
||||
#elif defined(USE_GC1109_PA)
|
||||
fem_type = GC1109_PA;
|
||||
LOG_INFO("Using GC1109 LoRa FEM");
|
||||
pinMode(LORA_PA_POWER, OUTPUT);
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
#if defined(ARCH_ESP32)
|
||||
@@ -55,6 +105,7 @@ void LoRaFEMInterface::init(void)
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, LOW);
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
fem_type = KCT8103L_PA;
|
||||
LOG_INFO("Using KCT8103L LoRa FEM");
|
||||
pinMode(LORA_PA_POWER, OUTPUT);
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
#if defined(ARCH_ESP32)
|
||||
@@ -73,6 +124,10 @@ void LoRaFEMInterface::init(void)
|
||||
|
||||
void LoRaFEMInterface::setSleepModeEnable(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
if (fem_type == GC1109_PA) {
|
||||
/*
|
||||
@@ -84,6 +139,7 @@ void LoRaFEMInterface::setSleepModeEnable(void)
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
// shutdown the PA
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, LOW);
|
||||
digitalWrite(LORA_PA_POWER, LOW);
|
||||
}
|
||||
#elif defined(USE_GC1109_PA)
|
||||
digitalWrite(LORA_GC1109_PA_EN, LOW);
|
||||
@@ -91,16 +147,22 @@ void LoRaFEMInterface::setSleepModeEnable(void)
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
// shutdown the PA
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, LOW);
|
||||
digitalWrite(LORA_PA_POWER, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LoRaFEMInterface::setTxModeEnable(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
if (fem_type == GC1109_PA) {
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, HIGH); // CPS: 1=full PA, 0=bypass (for RX, CPS is don't care)
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
@@ -108,6 +170,7 @@ void LoRaFEMInterface::setTxModeEnable(void)
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, HIGH); // CPS: 1=full PA, 0=bypass (for RX, CPS is don't care)
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
#endif
|
||||
@@ -115,11 +178,16 @@ void LoRaFEMInterface::setTxModeEnable(void)
|
||||
|
||||
void LoRaFEMInterface::setRxModeEnable(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
if (fem_type == GC1109_PA) {
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, LOW);
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
@@ -131,6 +199,7 @@ void LoRaFEMInterface::setRxModeEnable(void)
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH); // CSD=1: Chip enabled
|
||||
digitalWrite(LORA_GC1109_PA_TX_EN, LOW);
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
@@ -142,12 +211,14 @@ void LoRaFEMInterface::setRxModeEnable(void)
|
||||
|
||||
void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
|
||||
{
|
||||
#if defined(ARCH_ESP32)
|
||||
releaseSleepHolds();
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_V4
|
||||
// Keep GC1109 FEM powered during deep sleep so LNA remains active for RX wake.
|
||||
// Set PA_POWER and PA_EN HIGH (overrides SX126xInterface::sleep() shutdown),
|
||||
// then latch with RTC hold so the state survives deep sleep.
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
// Keep FEM rail powered during deep sleep so LoRa RX wake can work (GC1109 keeps LNA active; KCT8103L uses RX bypass).
|
||||
// Set PA_POWER HIGH (overrides SX126xInterface::sleep() shutdown), then latch with RTC hold so the state survives deep sleep.
|
||||
enableFEMPower();
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_PA_POWER);
|
||||
if (fem_type == GC1109_PA) {
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH);
|
||||
@@ -156,15 +227,11 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
|
||||
} else if (fem_type == KCT8103L_PA) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
} else {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); // RX bypass while MCU sleeps
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
|
||||
}
|
||||
#elif defined(USE_GC1109_PA)
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_GC1109_PA_EN, HIGH);
|
||||
#if defined(ARCH_ESP32)
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_PA_POWER);
|
||||
@@ -172,13 +239,11 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
|
||||
gpio_pulldown_en((gpio_num_t)LORA_GC1109_PA_TX_EN);
|
||||
#endif
|
||||
#elif defined(USE_KCT8103L_PA)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
if (lna_enabled) {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
|
||||
} else {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); // RX bypass while MCU sleeps
|
||||
#if defined(ARCH_ESP32)
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_PA_POWER);
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
|
||||
#endif
|
||||
@@ -227,4 +292,4 @@ int8_t LoRaFEMInterface::powerConversion(int8_t loraOutputPower)
|
||||
return loraOutputPower;
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
+37
-5
@@ -15,7 +15,7 @@ static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END =
|
||||
|
||||
#define PRESET(name) meshtastic_Config_LoRaConfig_ModemPreset_##name
|
||||
|
||||
// Override slot magic numbers for RegionProfile.overrideSlot
|
||||
// Override slot magic numbers for RegionInfo.overrideSlot
|
||||
#define OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH 0 // Use hash of primary channel name
|
||||
#define OVERRIDE_SLOT_PRESET_HASH -1 // Use hash of preset name instead
|
||||
// Positive values (1-32767) are explicit slot numbers
|
||||
@@ -30,8 +30,6 @@ struct RegionProfile {
|
||||
int8_t textThrottle; // throttle for text - future expansion
|
||||
int8_t positionThrottle; // throttle for location data - future expansion
|
||||
int8_t telemetryThrottle; // throttle for telemetry - future expansion
|
||||
int16_t overrideSlot; // a per-region override slot for if we need to fix it in place
|
||||
// Magic values: 0 = use channel name hash, -1 = use preset name hash, >0 = explicit slot
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -46,7 +44,8 @@ extern const RegionProfile PROFILE_EU868;
|
||||
extern const RegionProfile PROFILE_UNDEF;
|
||||
extern const RegionProfile PROFILE_LITE;
|
||||
extern const RegionProfile PROFILE_NARROW;
|
||||
// extern const RegionProfile PROFILE_HAM;
|
||||
extern const RegionProfile PROFILE_HAM_20KHZ;
|
||||
extern const RegionProfile PROFILE_HAM_100KHZ;
|
||||
|
||||
// Map from old region names to new region enums
|
||||
struct RegionInfo {
|
||||
@@ -59,7 +58,9 @@ struct RegionInfo {
|
||||
bool wideLora;
|
||||
const RegionProfile *profile;
|
||||
meshtastic_Config_LoRaConfig_ModemPreset defaultPreset;
|
||||
const char *name; // EU433 etc
|
||||
int16_t overrideSlot; // per-region override slot for frequency selection
|
||||
// Magic values: 0 = use channel name hash, -1 = use preset name hash, >0 = explicit slot
|
||||
const char *name; // EU433 etc
|
||||
|
||||
// Preset accessors (delegate through profile)
|
||||
meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; }
|
||||
@@ -77,6 +78,7 @@ extern const RegionInfo regions[];
|
||||
extern const RegionInfo *myRegion;
|
||||
|
||||
extern void initRegion();
|
||||
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
|
||||
|
||||
// Valid LoRa spread factor range and defaults
|
||||
constexpr uint8_t LORA_SF_MIN = 5;
|
||||
@@ -123,8 +125,18 @@ static inline float clampBandwidthKHz(float bwKHz)
|
||||
|
||||
static inline float bwCodeToKHz(uint16_t bwCode)
|
||||
{
|
||||
if (bwCode == 8)
|
||||
return 7.8f;
|
||||
if (bwCode == 10)
|
||||
return 10.4f;
|
||||
if (bwCode == 16)
|
||||
return 15.6f;
|
||||
if (bwCode == 21)
|
||||
return 20.8f;
|
||||
if (bwCode == 31)
|
||||
return 31.25f;
|
||||
if (bwCode == 42)
|
||||
return 41.7f;
|
||||
if (bwCode == 62)
|
||||
return 62.5f;
|
||||
if (bwCode == 200)
|
||||
@@ -140,8 +152,18 @@ static inline float bwCodeToKHz(uint16_t bwCode)
|
||||
|
||||
static inline uint16_t bwKHzToCode(float bwKHz)
|
||||
{
|
||||
if (bwKHz > 7.7f && bwKHz < 7.9f)
|
||||
return 8;
|
||||
if (bwKHz > 10.3f && bwKHz < 10.5f)
|
||||
return 10;
|
||||
if (bwKHz > 15.5f && bwKHz < 15.7f)
|
||||
return 16;
|
||||
if (bwKHz > 20.7f && bwKHz < 20.9f)
|
||||
return 21;
|
||||
if (bwKHz > 31.24f && bwKHz < 31.26f)
|
||||
return 31;
|
||||
if (bwKHz > 41.6f && bwKHz < 41.8f)
|
||||
return 42;
|
||||
if (bwKHz > 62.49f && bwKHz < 62.51f)
|
||||
return 62;
|
||||
if (bwKHz > 203.12f && bwKHz < 203.13f)
|
||||
@@ -219,6 +241,16 @@ static inline void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset
|
||||
cr = 6;
|
||||
sf = 8;
|
||||
break;
|
||||
case PRESET(TINY_FAST):
|
||||
bwKHz = 15.6f;
|
||||
cr = 5;
|
||||
sf = 7;
|
||||
break;
|
||||
case PRESET(TINY_SLOW):
|
||||
bwKHz = 15.6f;
|
||||
cr = 6;
|
||||
sf = 8;
|
||||
break;
|
||||
default: // LONG_FAST (or illegal)
|
||||
bwKHz = wideLora ? 812.5f : 250.0f;
|
||||
cr = 5;
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
|
||||
#include "meshUtils.h"
|
||||
#include "modules/NeighborInfoModule.h"
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
#endif
|
||||
#include "xmodem.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <algorithm>
|
||||
@@ -1986,6 +1989,14 @@ bool NodeDB::saveDeviceStateToDisk()
|
||||
|
||||
bool NodeDB::saveNodeDatabaseToDisk()
|
||||
{
|
||||
// Don't persist the node DB until this device has a PKI keypair
|
||||
// TODO: revisit when https://github.com/meshtastic/firmware/pull/10478 lands
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (owner.public_key.size != 32 && !owner.is_licensed) {
|
||||
LOG_DEBUG("Skip NodeDB without key");
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
||||
// and all writes will fail anyway. Device should be sleeping at this point anyway.
|
||||
@@ -2538,6 +2549,14 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
|
||||
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK,
|
||||
mp.via_mqtt); // Store if we received this packet via MQTT
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
// Only sample packets that arrived over LoRa.
|
||||
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && hopScalingModule) {
|
||||
uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp));
|
||||
hopScalingModule->samplePacketForHistogram(mp.from, hopCount);
|
||||
}
|
||||
#endif
|
||||
|
||||
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
|
||||
const int8_t hopsAway = getHopsAway(mp);
|
||||
if (hopsAway >= 0) {
|
||||
|
||||
+108
-53
@@ -49,18 +49,25 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_LITE[] = {PRESET(L
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET(NARROW_FAST), PRESET(NARROW_SLOW),
|
||||
MODEM_PRESET_END};
|
||||
|
||||
// Region profiles: bundle preset list + regulatory parameters shared across regions
|
||||
// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot
|
||||
const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1, 0};
|
||||
const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 1, 1, 0};
|
||||
const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 1, 1, 0};
|
||||
const RegionProfile PROFILE_LITE = {PRESETS_LITE, 0.4, 0.0375f, false, false, 0, 10, 10, 0};
|
||||
const RegionProfile PROFILE_NARROW = {PRESETS_NARROW, 0, 0.0104f, true, false, 0, 1, 1, 1};
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END};
|
||||
|
||||
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset) \
|
||||
// Region profiles: bundle preset list + regulatory parameters shared across regions
|
||||
// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle
|
||||
const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1};
|
||||
const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 1, 1};
|
||||
const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 1, 1};
|
||||
const RegionProfile PROFILE_LITE = {PRESETS_LITE, 0.4, 0.0375f, false, false, 0, 10, 10};
|
||||
const RegionProfile PROFILE_NARROW = {PRESETS_NARROW, 0, 0.0104f, true, false, 0, 1, 1};
|
||||
// Ham '20kHz' profile. 15.6kHz bandwidth coerced to 20kHz via padding.
|
||||
const RegionProfile PROFILE_HAM_20KHZ = {PRESETS_TINY, 0, 0.0022f, false, true, 0, 2, 2};
|
||||
// Ham '100kHz' profile. 62.5kHz bandwidth coerced to 100kHz via padding.
|
||||
const RegionProfile PROFILE_HAM_100KHZ = {PRESETS_NARROW, 0, 0.01875f, false, true, 0, 1, 1};
|
||||
|
||||
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset, \
|
||||
override_slot) \
|
||||
{ \
|
||||
meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \
|
||||
wide_lora, &profile_ptr, default_preset, #name \
|
||||
wide_lora, &profile_ptr, default_preset, override_slot, #name \
|
||||
}
|
||||
|
||||
const RegionInfo regions[] = {
|
||||
@@ -68,7 +75,7 @@ const RegionInfo regions[] = {
|
||||
https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
*/
|
||||
RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21]
|
||||
@@ -76,7 +83,7 @@ const RegionInfo regions[] = {
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf
|
||||
FIXME: https://github.com/meshtastic/firmware/issues/3371
|
||||
*/
|
||||
RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
/*
|
||||
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
@@ -91,33 +98,33 @@ const RegionInfo regions[] = {
|
||||
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
|
||||
*/
|
||||
RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST)),
|
||||
RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
*/
|
||||
RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf
|
||||
https://qiita.com/ammo0613/items/d952154f1195b64dc29f
|
||||
*/
|
||||
RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf
|
||||
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
|
||||
Also used in Brazil.
|
||||
*/
|
||||
RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions
|
||||
AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence
|
||||
NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100
|
||||
*/
|
||||
RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf
|
||||
@@ -125,13 +132,13 @@ const RegionInfo regions[] = {
|
||||
Note:
|
||||
- We do LBT, so 100% is allowed.
|
||||
*/
|
||||
RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0
|
||||
https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters
|
||||
*/
|
||||
RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor.
|
||||
@@ -139,40 +146,40 @@ const RegionInfo regions[] = {
|
||||
https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF
|
||||
https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283
|
||||
*/
|
||||
RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
*/
|
||||
RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752
|
||||
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
|
||||
*/
|
||||
RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
https://standard.nbtc.go.th/getattachment/Standards/%E0%B8%A1%E0%B8%B2%E0%B8%95%E0%B8%A3%E0%B8%90%E0%B8%B2%E0%B8%99%E0%B8%97%E0%B8%B2%E0%B8%87%E0%B9%80%E0%B8%97%E0%B8%84%E0%B8%99%E0%B8%B4%E0%B8%84%E0%B8%82%E0%B8%AD%E0%B8%87%E0%B9%80%E0%B8%84%E0%B8%A3%E0%B8%B7%E0%B9%88%E0%B8%AD%E0%B8%87%E0%B9%82%E0%B8%97%E0%B8%A3%E0%B8%84%E0%B8%A1%E0%B8%99%E0%B8%B2%E0%B8%84%E0%B8%A1/1033-2565.pdf.aspx?lang=th-TH
|
||||
Thailand 920–925 MHz set max TX power to 27 dBm and enforce 10% duty cycle, aligned with NBTC regulations.
|
||||
*/
|
||||
RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
433,05-434,7 Mhz 10 mW
|
||||
868,0-868,6 Mhz 25 mW
|
||||
https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf
|
||||
*/
|
||||
RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Malaysia
|
||||
433 - 435 MHz at 100mW, no restrictions.
|
||||
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
|
||||
*/
|
||||
RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Malaysia
|
||||
@@ -181,14 +188,14 @@ const RegionInfo regions[] = {
|
||||
Frequency hopping is used for 919 - 923 MHz.
|
||||
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
|
||||
*/
|
||||
RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Singapore
|
||||
SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions.
|
||||
https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf
|
||||
*/
|
||||
RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Philippines
|
||||
@@ -198,9 +205,9 @@ const RegionInfo regions[] = {
|
||||
https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135
|
||||
*/
|
||||
|
||||
RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Kazakhstan
|
||||
@@ -208,46 +215,85 @@ const RegionInfo regions[] = {
|
||||
863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields
|
||||
https://github.com/meshtastic/firmware/issues/7204
|
||||
*/
|
||||
RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Nepal
|
||||
865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use,
|
||||
specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf
|
||||
*/
|
||||
RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
Brazil
|
||||
902 - 907.5 MHz , 1W power limit, no duty cycle restrictions
|
||||
https://github.com/meshtastic/firmware/issues/3741
|
||||
*/
|
||||
RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
ITU Region 1 (Europe, Africa, Middle East, former USSR) amateur 2m allocation: 144.000 - 146.000 MHz.
|
||||
Power limit is the regulatory ceiling (1 W / 30 dBm) — individual hardware will cap below this
|
||||
via its own PA curve; the field here is just the legal upper bound.
|
||||
|
||||
Default slot: 26 (144.510 MHz)
|
||||
https://www.iaru-r1.org/wp-content/uploads/2020/12/VHF-Bandplan.pdf
|
||||
*/
|
||||
RDEF(ITU1_2M, 144.0f, 146.0f, 100, 30, false, false, PROFILE_HAM_20KHZ, PRESET(TINY_FAST), 26),
|
||||
|
||||
/*
|
||||
ITU Region 2 (Americas) amateur 2m allocation: 144.000 - 148.000 MHz.
|
||||
Typical admin rules (e.g. US FCC Part 97) allow well above 30 dBm for licensed operators.
|
||||
|
||||
Default slot: 51 (145.010 MHz)
|
||||
https://www.arrl.org/band-plan
|
||||
*/
|
||||
RDEF(ITU2_2M, 144.0f, 148.0f, 100, 30, false, false, PROFILE_HAM_20KHZ, PRESET(TINY_FAST), 51),
|
||||
|
||||
/*
|
||||
ITU Region 3 (Asia/Pacific) amateur 2m allocation: 144.000 - 148.000 MHz.
|
||||
Typical admin rules allow well above 30 dBm for licensed operators.
|
||||
|
||||
Default slot: 33 (144.650 MHz)
|
||||
https://www.iaru.org/wp-content/uploads/2020/01/R3-004-IARU-Region-3-Bandplan-rev.2.pdf
|
||||
https://www.wia.org.au/members/bandplans/data/documents/WIA%20Australian%20Band%20Plan%202026.pdf
|
||||
*/
|
||||
RDEF(ITU3_2M, 144.0f, 148.0f, 100, 30, false, false, PROFILE_HAM_20KHZ, PRESET(TINY_FAST), 33),
|
||||
|
||||
/*
|
||||
ITU Region 2 (Americas) amateur 1.25m '125cm' allocation: 220.000 - 225.000 MHz.
|
||||
Typical admin rules (e.g. US FCC Part 97) allow well above 30 dBm for licensed operators.
|
||||
Note: Some countries do not allocate 220-222 MHz (e.g. USA, Canada). Check local law!
|
||||
|
||||
Default slot: 37 (223.650 MHz)
|
||||
https://www.arrl.org/band-plan
|
||||
*/
|
||||
RDEF(ITU2_125CM, 220.0f, 225.0f, 100, 30, false, false, PROFILE_HAM_100KHZ, PRESET(NARROW_SLOW), 37),
|
||||
|
||||
/*
|
||||
2.4 GHZ WLAN Band equivalent. Only for SX128x chips.
|
||||
*/
|
||||
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST)),
|
||||
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD)
|
||||
Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm,
|
||||
duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123
|
||||
*/
|
||||
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST)),
|
||||
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST), 0),
|
||||
|
||||
/*
|
||||
EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz
|
||||
Channel centres at 869.442/869.525/869.608 MHz,
|
||||
10.4 kHz padding on channels, 27 dBm, duty cycle 10%
|
||||
*/
|
||||
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW)),
|
||||
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW), 1),
|
||||
|
||||
/*
|
||||
This needs to be last. Same as US.
|
||||
*/
|
||||
RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF, PRESET(LONG_FAST)),
|
||||
RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF, PRESET(LONG_FAST), 0),
|
||||
|
||||
};
|
||||
|
||||
@@ -833,6 +879,16 @@ bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &lo
|
||||
{
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
|
||||
// Reject unrecognized region codes (getRegion returns UNSET sentinel for unknown codes)
|
||||
if (newRegion->code != loraConfig.region) {
|
||||
char err_string[160];
|
||||
snprintf(err_string, sizeof(err_string), "Region code %d is not recognized", loraConfig.region);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If you are not licensed, you can't use ham regions.
|
||||
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
|
||||
char err_string[160];
|
||||
@@ -932,11 +988,11 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
|
||||
// overrideSlot: 0 = channel hash, -1 = preset hash, >0 = explicit slot
|
||||
uses_default_frequency_slot =
|
||||
(loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default
|
||||
(newRegion->profile->overrideSlot > 0 &&
|
||||
loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches explicit override slot
|
||||
((newRegion->profile->overrideSlot == OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH) &&
|
||||
(newRegion->overrideSlot > 0 &&
|
||||
loraConfig.channel_num == newRegion->overrideSlot) || // user setting matches explicit override slot
|
||||
((newRegion->overrideSlot == OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH) &&
|
||||
((uint32_t)(loraConfig.channel_num - 1) == channelNameHashSlot)) || // user setting matches channel name hash
|
||||
((newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) &&
|
||||
((newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) &&
|
||||
((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset name hash
|
||||
|
||||
// check if user setting different to preset name
|
||||
@@ -952,12 +1008,11 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
|
||||
if (clamp) {
|
||||
if (uses_custom_channel_name) { // clamp to channel name hash
|
||||
loraConfig.channel_num =
|
||||
channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
|
||||
} else if (newRegion->profile->overrideSlot > 0) { // clamp to explicit override slot
|
||||
loraConfig.channel_num =
|
||||
newRegion->profile->overrideSlot; // use the explicit override slot specified by the region profile
|
||||
channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
|
||||
} else if (newRegion->overrideSlot > 0) { // clamp to explicit override slot
|
||||
loraConfig.channel_num = newRegion->overrideSlot; // use the explicit override slot defined for this region
|
||||
uses_default_frequency_slot = true;
|
||||
} else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH && loraConfig.use_preset) {
|
||||
} else if (newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH && loraConfig.use_preset) {
|
||||
// clamp to preset name hash
|
||||
loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
|
||||
uses_default_frequency_slot = true;
|
||||
@@ -1076,9 +1131,9 @@ void RadioInterface::applyModemConfig()
|
||||
// NB: channel_num is also know as frequency slot but it's too late to fix now.
|
||||
if (uses_default_frequency_slot) {
|
||||
// Handle three override slot cases: explicit slot (>0), preset hash (-1), or channel hash (0)
|
||||
if (newRegion->profile->overrideSlot > 0) {
|
||||
channel_num = newRegion->profile->overrideSlot - 1; // explicit override slot (1-based to 0-based)
|
||||
} else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) {
|
||||
if (newRegion->overrideSlot > 0) {
|
||||
channel_num = newRegion->overrideSlot - 1; // explicit override slot (1-based to 0-based)
|
||||
} else if (newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) {
|
||||
channel_num = presetNameHashSlot; // use preset name hash
|
||||
} else {
|
||||
channel_num = channelNameHashSlot; // use channel name hash (default case)
|
||||
@@ -1111,9 +1166,9 @@ void RadioInterface::applyModemConfig()
|
||||
LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd,
|
||||
newRegion->freqEnd - newRegion->freqStart);
|
||||
LOG_INFO("numFreqSlots: %u x %.3fkHz", numFreqSlots, bw);
|
||||
if (newRegion->profile->overrideSlot > 0) {
|
||||
LOG_INFO("Using region explicit override slot: %d", newRegion->profile->overrideSlot);
|
||||
} else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) {
|
||||
if (newRegion->overrideSlot > 0) {
|
||||
LOG_INFO("Using region explicit override slot: %d", newRegion->overrideSlot);
|
||||
} else if (newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) {
|
||||
LOG_INFO("Using region preset name hash for slot selection");
|
||||
}
|
||||
LOG_INFO("channel_num: %d", channel_num + 1);
|
||||
|
||||
+29
-10
@@ -15,6 +15,9 @@
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
#endif
|
||||
@@ -22,8 +25,6 @@
|
||||
#if ARCH_PORTDUINO
|
||||
#include "Throttle.h"
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#endif
|
||||
#if ENABLE_JSON_LOGGING || ARCH_PORTDUINO
|
||||
#include "serialization/MeshPacketSerializer.h"
|
||||
#endif
|
||||
|
||||
@@ -357,6 +358,30 @@ ErrorCode Router::send(meshtastic_MeshPacket *p)
|
||||
p->from = getFrom(p);
|
||||
|
||||
p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // set the relayer to us
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
// Apply HopScaling hop recommendation to routine outgoing broadcasts
|
||||
if (isFromUs(p) && isBroadcast(p->to) && hopScalingModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
|
||||
switch (p->decoded.portnum) {
|
||||
case meshtastic_PortNum_POSITION_APP:
|
||||
case meshtastic_PortNum_TELEMETRY_APP:
|
||||
case meshtastic_PortNum_NODEINFO_APP:
|
||||
case meshtastic_PortNum_NEIGHBORINFO_APP: {
|
||||
uint8_t variableHopLimit = hopScalingModule->getLastRequiredHop();
|
||||
|
||||
// Never exceed user-configured hop_limit
|
||||
if (variableHopLimit < p->hop_limit) {
|
||||
LOG_DEBUG("[HOPSCALE] hop_limit %u -> %u for portnum %u", p->hop_limit, variableHopLimit, p->decoded.portnum);
|
||||
p->hop_limit = variableHopLimit;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// If we are the original transmitter, set the hop limit with which we start
|
||||
if (isFromUs(p))
|
||||
p->hop_start = p->hop_limit;
|
||||
@@ -555,9 +580,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
} */
|
||||
|
||||
printPacket("decoded message", p);
|
||||
#if ENABLE_JSON_LOGGING
|
||||
LOG_TRACE("%s", MeshPacketSerializer::JsonSerialize(p, false).c_str());
|
||||
#elif ARCH_PORTDUINO
|
||||
#if ARCH_PORTDUINO
|
||||
if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) {
|
||||
LOG_TRACE("%s", MeshPacketSerializer::JsonSerialize(p, false).c_str());
|
||||
} else if (portduino_config.JSONFilename != "") {
|
||||
@@ -852,11 +875,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
||||
|
||||
void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
||||
{
|
||||
#if ENABLE_JSON_LOGGING
|
||||
// Even ignored packets get logged in the trace
|
||||
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
|
||||
LOG_TRACE("%s", MeshPacketSerializer::JsonSerializeEncrypted(p).c_str());
|
||||
#elif ARCH_PORTDUINO
|
||||
#if ARCH_PORTDUINO
|
||||
// Even ignored packets get logged in the trace
|
||||
if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) {
|
||||
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
|
||||
|
||||
@@ -198,6 +198,28 @@ typedef struct _meshtastic_LockdownAuth {
|
||||
way to reset the session clock is a reboot, which costs a boot
|
||||
from the on-flash, HMAC-bound counter. */
|
||||
uint32_t max_session_seconds;
|
||||
/* Disable lockdown mode. Requires a valid passphrase in the same
|
||||
message (the device must prove the operator owns it before
|
||||
reverting at-rest encryption). On success the firmware decrypts
|
||||
every stored config / channel / nodedb file back to plaintext,
|
||||
removes the wrapped DEK, unlock token, monotonic-counter, and
|
||||
backoff files, and reboots out of lockdown.
|
||||
|
||||
This is the inverse of the provision/unlock path: it is how the
|
||||
client app's "lockdown mode" toggle returns a device to normal
|
||||
operation.
|
||||
|
||||
NOT reversed by this operation: APPROTECT. Once the debug port
|
||||
lockout has been burned (on silicon where it is effective) it is
|
||||
permanent — disabling lockdown decrypts your data and removes the
|
||||
access gates, but the SWD/JTAG port stays locked for the life of
|
||||
the device (recoverable only via a full chip erase over a debug
|
||||
probe, which destroys all data). Clients should make this
|
||||
irreversibility clear at the moment lockdown is first enabled.
|
||||
|
||||
When true the passphrase field is still required; boots_remaining,
|
||||
valid_until_epoch, max_session_seconds, and lock_now are ignored. */
|
||||
bool disable;
|
||||
} meshtastic_LockdownAuth;
|
||||
|
||||
/* Parameters for setting up Meshtastic for ameteur radio usage */
|
||||
@@ -521,7 +543,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}}
|
||||
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
|
||||
@@ -534,7 +556,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}}
|
||||
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
|
||||
@@ -557,6 +579,7 @@ extern "C" {
|
||||
#define meshtastic_LockdownAuth_valid_until_epoch_tag 3
|
||||
#define meshtastic_LockdownAuth_lock_now_tag 4
|
||||
#define meshtastic_LockdownAuth_max_session_seconds_tag 5
|
||||
#define meshtastic_LockdownAuth_disable_tag 6
|
||||
#define meshtastic_HamParameters_call_sign_tag 1
|
||||
#define meshtastic_HamParameters_tx_power_tag 2
|
||||
#define meshtastic_HamParameters_frequency_tag 3
|
||||
@@ -754,7 +777,8 @@ X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \
|
||||
X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \
|
||||
X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \
|
||||
X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \
|
||||
X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5)
|
||||
X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) \
|
||||
X(a, STATIC, SINGULAR, BOOL, disable, 6)
|
||||
#define meshtastic_LockdownAuth_CALLBACK NULL
|
||||
#define meshtastic_LockdownAuth_DEFAULT NULL
|
||||
|
||||
@@ -869,7 +893,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
|
||||
#define meshtastic_AdminMessage_size 511
|
||||
#define meshtastic_HamParameters_size 31
|
||||
#define meshtastic_KeyVerificationAdmin_size 25
|
||||
#define meshtastic_LockdownAuth_size 54
|
||||
#define meshtastic_LockdownAuth_size 56
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_size 496
|
||||
#define meshtastic_SCD30_config_size 27
|
||||
#define meshtastic_SCD4X_config_size 29
|
||||
|
||||
@@ -309,7 +309,11 @@ typedef enum _meshtastic_Config_LoRaConfig_RegionCode {
|
||||
meshtastic_Config_LoRaConfig_RegionCode_ITU2_70CM = 35,
|
||||
/* ITU Region 3 Amateur Radio 70cm band (430-450 MHz)
|
||||
Note: Some countries do not allocate 440-450 MHz. Check local law! */
|
||||
meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM = 36
|
||||
meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM = 36,
|
||||
/* ITU Region 2 Amateur Radio 1.25m '125cm' band (220-225 MHz)
|
||||
Note: Some countries do not allocate 220-222 MHz (Ex: USA/Canada).
|
||||
Check local law! */
|
||||
meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM = 37
|
||||
} meshtastic_Config_LoRaConfig_RegionCode;
|
||||
|
||||
/* Standard predefined channel settings
|
||||
@@ -356,7 +360,21 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset {
|
||||
/* Narrow Slow
|
||||
Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth.
|
||||
Comparable link budget and data rate to LONG_FAST. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13,
|
||||
/* Tiny Fast
|
||||
Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth.
|
||||
Many regions limit data transmission bandwidth in lower amateur bands (2 Meter).
|
||||
Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths.
|
||||
Only compatible with SX127x and SX126x chipsets.
|
||||
Comparable link budget and data rate to LONG_FAST. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_TINY_FAST = 14,
|
||||
/* Tiny Slow
|
||||
Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth.
|
||||
Many regions limit data transmission bandwidth in lower amateur bands (2 Meter).
|
||||
Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths.
|
||||
Only compatible with SX127x and SX126x chipsets.
|
||||
Comparable link budget and data rate to LONG_MODERATE. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW = 15
|
||||
} meshtastic_Config_LoRaConfig_ModemPreset;
|
||||
|
||||
typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode {
|
||||
@@ -745,12 +763,12 @@ extern "C" {
|
||||
#define _meshtastic_Config_DisplayConfig_CompassOrientation_ARRAYSIZE ((meshtastic_Config_DisplayConfig_CompassOrientation)(meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED+1))
|
||||
|
||||
#define _meshtastic_Config_LoRaConfig_RegionCode_MIN meshtastic_Config_LoRaConfig_RegionCode_UNSET
|
||||
#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM
|
||||
#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM+1))
|
||||
#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM
|
||||
#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM+1))
|
||||
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1))
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW+1))
|
||||
|
||||
#define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED
|
||||
#define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT
|
||||
|
||||
@@ -621,7 +621,9 @@ typedef enum _meshtastic_MeshPacket_TransportMechanism {
|
||||
/* Arrived via Multicast UDP */
|
||||
meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP = 6,
|
||||
/* Arrived via API connection */
|
||||
meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API = 7
|
||||
meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API = 7,
|
||||
/* Arrived via Unicast UDP */
|
||||
meshtastic_MeshPacket_TransportMechanism_TRANSPORT_UNICAST_UDP = 8
|
||||
} meshtastic_MeshPacket_TransportMechanism;
|
||||
|
||||
/* Log levels, chosen to match python logging conventions. */
|
||||
@@ -658,7 +660,14 @@ typedef enum _meshtastic_LockdownStatus_State {
|
||||
token's TTL. */
|
||||
meshtastic_LockdownStatus_State_UNLOCKED = 3,
|
||||
/* Passphrase rejected. backoff_seconds is non-zero when rate-limited. */
|
||||
meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4
|
||||
meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4,
|
||||
/* Lockdown is supported by this firmware but not currently active
|
||||
(no passphrase has been provisioned, or it was disabled via
|
||||
AdminMessage.lockdown_auth.disable). The device is operating in
|
||||
normal, non-encrypted mode. Clients render the lockdown-mode
|
||||
toggle as OFF on receiving this. Distinct from NEEDS_PROVISION,
|
||||
which is only used during an in-progress enable flow. */
|
||||
meshtastic_LockdownStatus_State_DISABLED = 5
|
||||
} meshtastic_LockdownStatus_State;
|
||||
|
||||
/* Struct definitions */
|
||||
@@ -1525,16 +1534,16 @@ extern "C" {
|
||||
#define _meshtastic_MeshPacket_Delayed_ARRAYSIZE ((meshtastic_MeshPacket_Delayed)(meshtastic_MeshPacket_Delayed_DELAYED_DIRECT+1))
|
||||
|
||||
#define _meshtastic_MeshPacket_TransportMechanism_MIN meshtastic_MeshPacket_TransportMechanism_TRANSPORT_INTERNAL
|
||||
#define _meshtastic_MeshPacket_TransportMechanism_MAX meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API
|
||||
#define _meshtastic_MeshPacket_TransportMechanism_ARRAYSIZE ((meshtastic_MeshPacket_TransportMechanism)(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API+1))
|
||||
#define _meshtastic_MeshPacket_TransportMechanism_MAX meshtastic_MeshPacket_TransportMechanism_TRANSPORT_UNICAST_UDP
|
||||
#define _meshtastic_MeshPacket_TransportMechanism_ARRAYSIZE ((meshtastic_MeshPacket_TransportMechanism)(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_UNICAST_UDP+1))
|
||||
|
||||
#define _meshtastic_LogRecord_Level_MIN meshtastic_LogRecord_Level_UNSET
|
||||
#define _meshtastic_LogRecord_Level_MAX meshtastic_LogRecord_Level_CRITICAL
|
||||
#define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1))
|
||||
|
||||
#define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED
|
||||
#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_UNLOCK_FAILED
|
||||
#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_UNLOCK_FAILED+1))
|
||||
#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_DISABLED
|
||||
#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_DISABLED+1))
|
||||
|
||||
#define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource
|
||||
#define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource
|
||||
|
||||
+253
-174
@@ -11,11 +11,12 @@
|
||||
#endif
|
||||
#include "SPILock.h"
|
||||
#include "power.h"
|
||||
#include "serialization/JSON.h"
|
||||
#include <FSCommon.h>
|
||||
#include <HTTPBodyParser.hpp>
|
||||
#include <HTTPMultipartBodyParser.hpp>
|
||||
#include <HTTPURLEncodedBodyParser.hpp>
|
||||
#include <cmath>
|
||||
#include <sstream>
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
#include "esp_task_wdt.h"
|
||||
@@ -259,40 +260,95 @@ void htmlDeleteDir(const char *dirname)
|
||||
root.close();
|
||||
}
|
||||
|
||||
JSONArray htmlListDir(const char *dirname, uint8_t levels)
|
||||
// Escape a string into a JSON double-quoted literal. Matches the previous
|
||||
// SimpleJSON StringifyString behavior (0x00-0x1F and 0x7F -> \u00xx lowercase,
|
||||
// escapes " \ / \b \f \n \r \t, UTF-8 passes through unchanged).
|
||||
static std::string jsonEscape(const std::string &str)
|
||||
{
|
||||
std::string out = "\"";
|
||||
for (size_t i = 0; i < str.size(); ++i) {
|
||||
char chr = str[i];
|
||||
if (chr == '"' || chr == '\\' || chr == '/') {
|
||||
out += '\\';
|
||||
out += chr;
|
||||
} else if (chr == '\b') {
|
||||
out += "\\b";
|
||||
} else if (chr == '\f') {
|
||||
out += "\\f";
|
||||
} else if (chr == '\n') {
|
||||
out += "\\n";
|
||||
} else if (chr == '\r') {
|
||||
out += "\\r";
|
||||
} else if (chr == '\t') {
|
||||
out += "\\t";
|
||||
} else if ((unsigned char)chr < 0x20 || chr == 0x7F) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "\\u%04x", (unsigned char)chr);
|
||||
out += buf;
|
||||
} else {
|
||||
out += chr;
|
||||
}
|
||||
}
|
||||
out += "\"";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Format a numeric value the way the previous SimpleJSON serializer did
|
||||
// (std::stringstream with precision 15, NaN/Inf -> "null").
|
||||
static std::string jsonNum(double v)
|
||||
{
|
||||
if (std::isinf(v) || std::isnan(v))
|
||||
return "null";
|
||||
std::ostringstream ss;
|
||||
ss.precision(15);
|
||||
ss << v;
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
// Build a serialized JSON array string listing files in `dirname`.
|
||||
// Subdirectories recurse as nested arrays (up to `levels` deep).
|
||||
std::string htmlListDir(const char *dirname, uint8_t levels)
|
||||
{
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
JSONArray fileList;
|
||||
std::string out = "[";
|
||||
bool first = true;
|
||||
if (!root) {
|
||||
return fileList;
|
||||
out += "]";
|
||||
return out;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
return fileList;
|
||||
out += "]";
|
||||
return out;
|
||||
}
|
||||
|
||||
// iterate over the file list
|
||||
File file = root.openNextFile();
|
||||
while (file) {
|
||||
std::string element;
|
||||
bool haveElement = false;
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
fileList.push_back(new JSONValue(htmlListDir(file.path(), levels - 1)));
|
||||
element = htmlListDir(file.path(), levels - 1);
|
||||
#else
|
||||
fileList.push_back(new JSONValue(htmlListDir(file.name(), levels - 1)));
|
||||
element = htmlListDir(file.name(), levels - 1);
|
||||
#endif
|
||||
haveElement = true;
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
JSONObject thisFileMap;
|
||||
thisFileMap["size"] = new JSONValue((int)file.size());
|
||||
#ifdef ARCH_ESP32
|
||||
String fileName = String(file.path()).substring(1);
|
||||
thisFileMap["name"] = new JSONValue(fileName.c_str());
|
||||
#else
|
||||
String fileName = String(file.name()).substring(1);
|
||||
thisFileMap["name"] = new JSONValue(fileName.c_str());
|
||||
#endif
|
||||
String tempName = String(file.name()).substring(1);
|
||||
// Keys in the previous std::map<string,...> were emitted in
|
||||
// alphabetical order: name, nameModified, size.
|
||||
element = "{";
|
||||
element += jsonEscape("name");
|
||||
element += ":";
|
||||
element += jsonEscape(fileName.c_str());
|
||||
if (tempName.endsWith(".gz")) {
|
||||
#ifdef ARCH_ESP32
|
||||
String modifiedFile = String(file.path()).substring(1);
|
||||
@@ -300,15 +356,30 @@ JSONArray htmlListDir(const char *dirname, uint8_t levels)
|
||||
String modifiedFile = String(file.name()).substring(1);
|
||||
#endif
|
||||
modifiedFile.remove((modifiedFile.length() - 3), 3);
|
||||
thisFileMap["nameModified"] = new JSONValue(modifiedFile.c_str());
|
||||
element += ",";
|
||||
element += jsonEscape("nameModified");
|
||||
element += ":";
|
||||
element += jsonEscape(modifiedFile.c_str());
|
||||
}
|
||||
fileList.push_back(new JSONValue(thisFileMap));
|
||||
element += ",";
|
||||
element += jsonEscape("size");
|
||||
element += ":";
|
||||
element += jsonNum((int)file.size());
|
||||
element += "}";
|
||||
haveElement = true;
|
||||
}
|
||||
if (haveElement) {
|
||||
if (!first)
|
||||
out += ",";
|
||||
out += element;
|
||||
first = false;
|
||||
}
|
||||
file.close();
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
return fileList;
|
||||
out += "]";
|
||||
return out;
|
||||
}
|
||||
|
||||
void handleFsBrowseStatic(HTTPRequest *req, HTTPResponse *res)
|
||||
@@ -318,28 +389,25 @@ void handleFsBrowseStatic(HTTPRequest *req, HTTPResponse *res)
|
||||
res->setHeader("Access-Control-Allow-Methods", "GET");
|
||||
|
||||
concurrency::LockGuard g(spiLock);
|
||||
auto fileList = htmlListDir("/static", 10);
|
||||
std::string fileList = htmlListDir("/static", 10);
|
||||
|
||||
// create json output structure
|
||||
JSONObject filesystemObj;
|
||||
filesystemObj["total"] = new JSONValue((int)FSCom.totalBytes());
|
||||
filesystemObj["used"] = new JSONValue((int)FSCom.usedBytes());
|
||||
filesystemObj["free"] = new JSONValue(int(FSCom.totalBytes() - FSCom.usedBytes()));
|
||||
uint64_t total = FSCom.totalBytes();
|
||||
uint64_t used = FSCom.usedBytes();
|
||||
|
||||
JSONObject jsonObjInner;
|
||||
jsonObjInner["files"] = new JSONValue(fileList);
|
||||
jsonObjInner["filesystem"] = new JSONValue(filesystemObj);
|
||||
// Key order matches the previous std::map-based emission (alphabetical).
|
||||
std::string out;
|
||||
out.reserve(fileList.size() + 128);
|
||||
out += "{\"data\":{\"files\":";
|
||||
out += fileList;
|
||||
out += ",\"filesystem\":{\"free\":";
|
||||
out += jsonNum((int)(total - used));
|
||||
out += ",\"total\":";
|
||||
out += jsonNum((int)total);
|
||||
out += ",\"used\":";
|
||||
out += jsonNum((int)used);
|
||||
out += "}},\"status\":\"ok\"}";
|
||||
|
||||
JSONObject jsonObjOuter;
|
||||
jsonObjOuter["data"] = new JSONValue(jsonObjInner);
|
||||
jsonObjOuter["status"] = new JSONValue("ok");
|
||||
|
||||
JSONValue *value = new JSONValue(jsonObjOuter);
|
||||
|
||||
std::string jsonString = value->Stringify();
|
||||
res->print(jsonString.c_str());
|
||||
|
||||
delete value;
|
||||
res->print(out.c_str());
|
||||
}
|
||||
|
||||
void handleFsDeleteStatic(HTTPRequest *req, HTTPResponse *res)
|
||||
@@ -354,27 +422,13 @@ void handleFsDeleteStatic(HTTPRequest *req, HTTPResponse *res)
|
||||
if (params->getQueryParameter("delete", paramValDelete)) {
|
||||
std::string pathDelete = "/" + paramValDelete;
|
||||
concurrency::LockGuard g(spiLock);
|
||||
if (FSCom.remove(pathDelete.c_str())) {
|
||||
|
||||
LOG_INFO("%s", pathDelete.c_str());
|
||||
JSONObject jsonObjOuter;
|
||||
jsonObjOuter["status"] = new JSONValue("ok");
|
||||
JSONValue *value = new JSONValue(jsonObjOuter);
|
||||
std::string jsonString = value->Stringify();
|
||||
res->print(jsonString.c_str());
|
||||
delete value;
|
||||
return;
|
||||
} else {
|
||||
|
||||
LOG_INFO("%s", pathDelete.c_str());
|
||||
JSONObject jsonObjOuter;
|
||||
jsonObjOuter["status"] = new JSONValue("Error");
|
||||
JSONValue *value = new JSONValue(jsonObjOuter);
|
||||
std::string jsonString = value->Stringify();
|
||||
res->print(jsonString.c_str());
|
||||
delete value;
|
||||
return;
|
||||
}
|
||||
const char *status = FSCom.remove(pathDelete.c_str()) ? "ok" : "Error";
|
||||
LOG_INFO("%s", pathDelete.c_str());
|
||||
std::string out = "{\"status\":";
|
||||
out += jsonEscape(status);
|
||||
out += "}";
|
||||
res->print(out.c_str());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,95 +669,113 @@ void handleReport(HTTPRequest *req, HTTPResponse *res)
|
||||
res->println("<pre>");
|
||||
}
|
||||
|
||||
// Helper lambda to create JSON array and clean up memory properly
|
||||
auto createJSONArrayFromLog = [](const uint32_t *logArray, int count) -> JSONValue * {
|
||||
JSONArray tempArray;
|
||||
auto arrayFromLog = [](const uint32_t *logArray, int count) -> std::string {
|
||||
std::string s = "[";
|
||||
for (int i = 0; i < count; i++) {
|
||||
tempArray.push_back(new JSONValue((int)logArray[i]));
|
||||
if (i)
|
||||
s += ",";
|
||||
s += jsonNum((int)logArray[i]);
|
||||
}
|
||||
JSONValue *result = new JSONValue(tempArray);
|
||||
// Note: Don't delete tempArray elements here - JSONValue now owns them
|
||||
return result;
|
||||
s += "]";
|
||||
return s;
|
||||
};
|
||||
|
||||
// data->airtime->tx_log
|
||||
uint32_t *logArray;
|
||||
logArray = airTime->airtimeReport(TX_LOG);
|
||||
JSONValue *txLogJsonValue = createJSONArrayFromLog(logArray, airTime->getPeriodsToLog());
|
||||
|
||||
// data->airtime->rx_log
|
||||
std::string txLog = arrayFromLog(logArray, airTime->getPeriodsToLog());
|
||||
logArray = airTime->airtimeReport(RX_LOG);
|
||||
JSONValue *rxLogJsonValue = createJSONArrayFromLog(logArray, airTime->getPeriodsToLog());
|
||||
|
||||
// data->airtime->rx_all_log
|
||||
std::string rxLog = arrayFromLog(logArray, airTime->getPeriodsToLog());
|
||||
logArray = airTime->airtimeReport(RX_ALL_LOG);
|
||||
JSONValue *rxAllLogJsonValue = createJSONArrayFromLog(logArray, airTime->getPeriodsToLog());
|
||||
std::string rxAllLog = arrayFromLog(logArray, airTime->getPeriodsToLog());
|
||||
|
||||
// data->airtime
|
||||
JSONObject jsonObjAirtime;
|
||||
jsonObjAirtime["tx_log"] = txLogJsonValue;
|
||||
jsonObjAirtime["rx_log"] = rxLogJsonValue;
|
||||
jsonObjAirtime["rx_all_log"] = rxAllLogJsonValue;
|
||||
jsonObjAirtime["channel_utilization"] = new JSONValue(airTime->channelUtilizationPercent());
|
||||
jsonObjAirtime["utilization_tx"] = new JSONValue(airTime->utilizationTXPercent());
|
||||
jsonObjAirtime["seconds_since_boot"] = new JSONValue(int(airTime->getSecondsSinceBoot()));
|
||||
jsonObjAirtime["seconds_per_period"] = new JSONValue(int(airTime->getSecondsPerPeriod()));
|
||||
jsonObjAirtime["periods_to_log"] = new JSONValue(airTime->getPeriodsToLog());
|
||||
|
||||
// data->wifi
|
||||
JSONObject jsonObjWifi;
|
||||
jsonObjWifi["rssi"] = new JSONValue(WiFi.RSSI());
|
||||
String wifiIPString = WiFi.localIP().toString();
|
||||
std::string wifiIP = wifiIPString.c_str();
|
||||
jsonObjWifi["ip"] = new JSONValue(wifiIP.c_str());
|
||||
|
||||
// data->memory
|
||||
JSONObject jsonObjMemory;
|
||||
jsonObjMemory["heap_total"] = new JSONValue((int)memGet.getHeapSize());
|
||||
jsonObjMemory["heap_free"] = new JSONValue((int)memGet.getFreeHeap());
|
||||
jsonObjMemory["psram_total"] = new JSONValue((int)memGet.getPsramSize());
|
||||
jsonObjMemory["psram_free"] = new JSONValue((int)memGet.getFreePsram());
|
||||
spiLock->lock();
|
||||
jsonObjMemory["fs_total"] = new JSONValue((int)FSCom.totalBytes());
|
||||
jsonObjMemory["fs_used"] = new JSONValue((int)FSCom.usedBytes());
|
||||
jsonObjMemory["fs_free"] = new JSONValue(int(FSCom.totalBytes() - FSCom.usedBytes()));
|
||||
uint64_t fsTotal = FSCom.totalBytes();
|
||||
uint64_t fsUsed = FSCom.usedBytes();
|
||||
spiLock->unlock();
|
||||
|
||||
// data->power
|
||||
JSONObject jsonObjPower;
|
||||
jsonObjPower["battery_percent"] = new JSONValue(powerStatus->getBatteryChargePercent());
|
||||
jsonObjPower["battery_voltage_mv"] = new JSONValue(powerStatus->getBatteryVoltageMv());
|
||||
jsonObjPower["has_battery"] = new JSONValue(BoolToString(powerStatus->getHasBattery()));
|
||||
jsonObjPower["has_usb"] = new JSONValue(BoolToString(powerStatus->getHasUSB()));
|
||||
jsonObjPower["is_charging"] = new JSONValue(BoolToString(powerStatus->getIsCharging()));
|
||||
// Emit keys in the same alphabetical order as the previous
|
||||
// std::map-based JSON output to keep responses byte-compatible.
|
||||
std::string out;
|
||||
out.reserve(1024);
|
||||
out += "{\"data\":{";
|
||||
|
||||
// data->device
|
||||
JSONObject jsonObjDevice;
|
||||
jsonObjDevice["reboot_counter"] = new JSONValue((int)myNodeInfo.reboot_count);
|
||||
// airtime
|
||||
out += "\"airtime\":{";
|
||||
out += "\"channel_utilization\":";
|
||||
out += jsonNum(airTime->channelUtilizationPercent());
|
||||
out += ",\"periods_to_log\":";
|
||||
out += jsonNum(airTime->getPeriodsToLog());
|
||||
out += ",\"rx_all_log\":";
|
||||
out += rxAllLog;
|
||||
out += ",\"rx_log\":";
|
||||
out += rxLog;
|
||||
out += ",\"seconds_per_period\":";
|
||||
out += jsonNum((int)airTime->getSecondsPerPeriod());
|
||||
out += ",\"seconds_since_boot\":";
|
||||
out += jsonNum((int)airTime->getSecondsSinceBoot());
|
||||
out += ",\"tx_log\":";
|
||||
out += txLog;
|
||||
out += ",\"utilization_tx\":";
|
||||
out += jsonNum(airTime->utilizationTXPercent());
|
||||
out += "}";
|
||||
|
||||
// data->radio
|
||||
JSONObject jsonObjRadio;
|
||||
jsonObjRadio["frequency"] = new JSONValue(RadioLibInterface::instance->getFreq());
|
||||
jsonObjRadio["lora_channel"] = new JSONValue((int)RadioLibInterface::instance->getChannelNum() + 1);
|
||||
// device
|
||||
out += ",\"device\":{\"reboot_counter\":";
|
||||
out += jsonNum((int)myNodeInfo.reboot_count);
|
||||
out += "}";
|
||||
|
||||
// collect data to inner data object
|
||||
JSONObject jsonObjInner;
|
||||
jsonObjInner["airtime"] = new JSONValue(jsonObjAirtime);
|
||||
jsonObjInner["wifi"] = new JSONValue(jsonObjWifi);
|
||||
jsonObjInner["memory"] = new JSONValue(jsonObjMemory);
|
||||
jsonObjInner["power"] = new JSONValue(jsonObjPower);
|
||||
jsonObjInner["device"] = new JSONValue(jsonObjDevice);
|
||||
jsonObjInner["radio"] = new JSONValue(jsonObjRadio);
|
||||
// memory
|
||||
out += ",\"memory\":{";
|
||||
out += "\"fs_free\":";
|
||||
out += jsonNum((int)(fsTotal - fsUsed));
|
||||
out += ",\"fs_total\":";
|
||||
out += jsonNum((int)fsTotal);
|
||||
out += ",\"fs_used\":";
|
||||
out += jsonNum((int)fsUsed);
|
||||
out += ",\"heap_free\":";
|
||||
out += jsonNum((int)memGet.getFreeHeap());
|
||||
out += ",\"heap_total\":";
|
||||
out += jsonNum((int)memGet.getHeapSize());
|
||||
out += ",\"psram_free\":";
|
||||
out += jsonNum((int)memGet.getFreePsram());
|
||||
out += ",\"psram_total\":";
|
||||
out += jsonNum((int)memGet.getPsramSize());
|
||||
out += "}";
|
||||
|
||||
// create json output structure
|
||||
JSONObject jsonObjOuter;
|
||||
jsonObjOuter["data"] = new JSONValue(jsonObjInner);
|
||||
jsonObjOuter["status"] = new JSONValue("ok");
|
||||
// serialize and write it to the stream
|
||||
JSONValue *value = new JSONValue(jsonObjOuter);
|
||||
std::string jsonString = value->Stringify();
|
||||
res->print(jsonString.c_str());
|
||||
delete value;
|
||||
// power (has_* / is_charging were serialized as the strings "true"/"false")
|
||||
out += ",\"power\":{";
|
||||
out += "\"battery_percent\":";
|
||||
out += jsonNum(powerStatus->getBatteryChargePercent());
|
||||
out += ",\"battery_voltage_mv\":";
|
||||
out += jsonNum(powerStatus->getBatteryVoltageMv());
|
||||
out += ",\"has_battery\":";
|
||||
out += jsonEscape(BoolToString(powerStatus->getHasBattery()));
|
||||
out += ",\"has_usb\":";
|
||||
out += jsonEscape(BoolToString(powerStatus->getHasUSB()));
|
||||
out += ",\"is_charging\":";
|
||||
out += jsonEscape(BoolToString(powerStatus->getIsCharging()));
|
||||
out += "}";
|
||||
|
||||
// radio
|
||||
out += ",\"radio\":{\"frequency\":";
|
||||
out += jsonNum(RadioLibInterface::instance->getFreq());
|
||||
out += ",\"lora_channel\":";
|
||||
out += jsonNum((int)RadioLibInterface::instance->getChannelNum() + 1);
|
||||
out += "}";
|
||||
|
||||
// wifi
|
||||
out += ",\"wifi\":{\"ip\":";
|
||||
out += jsonEscape(wifiIP);
|
||||
out += ",\"rssi\":";
|
||||
out += jsonNum(WiFi.RSSI());
|
||||
out += "}";
|
||||
|
||||
out += "},\"status\":\"ok\"}";
|
||||
|
||||
res->print(out.c_str());
|
||||
}
|
||||
|
||||
void handleNodes(HTTPRequest *req, HTTPResponse *res)
|
||||
@@ -724,58 +796,66 @@ void handleNodes(HTTPRequest *req, HTTPResponse *res)
|
||||
res->println("<pre>");
|
||||
}
|
||||
|
||||
JSONArray nodesArray;
|
||||
std::string out;
|
||||
out.reserve(2048);
|
||||
out += "{\"data\":{\"nodes\":[";
|
||||
|
||||
bool firstNode = true;
|
||||
uint32_t readIndex = 0;
|
||||
const meshtastic_NodeInfoLite *tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
||||
while (tempNodeInfo != NULL) {
|
||||
if (nodeInfoLiteHasUser(tempNodeInfo)) {
|
||||
JSONObject node;
|
||||
|
||||
char id[16];
|
||||
snprintf(id, sizeof(id), "!%08x", tempNodeInfo->num);
|
||||
|
||||
node["id"] = new JSONValue(id);
|
||||
node["snr"] = new JSONValue(tempNodeInfo->snr);
|
||||
node["via_mqtt"] = new JSONValue(BoolToString(nodeInfoLiteViaMqtt(tempNodeInfo)));
|
||||
node["last_heard"] = new JSONValue((int)tempNodeInfo->last_heard);
|
||||
node["position"] = new JSONValue();
|
||||
|
||||
std::string position;
|
||||
if (nodeDB->hasValidPosition(tempNodeInfo)) {
|
||||
meshtastic_PositionLite posLite;
|
||||
if (nodeDB->copyNodePosition(tempNodeInfo->num, posLite)) {
|
||||
JSONObject position;
|
||||
position["latitude"] = new JSONValue((float)posLite.latitude_i * 1e-7);
|
||||
position["longitude"] = new JSONValue((float)posLite.longitude_i * 1e-7);
|
||||
position["altitude"] = new JSONValue((int)posLite.altitude);
|
||||
node["position"] = new JSONValue(position);
|
||||
position = "{\"altitude\":";
|
||||
position += jsonNum((int)posLite.altitude);
|
||||
position += ",\"latitude\":";
|
||||
position += jsonNum((float)posLite.latitude_i * 1e-7);
|
||||
position += ",\"longitude\":";
|
||||
position += jsonNum((float)posLite.longitude_i * 1e-7);
|
||||
position += "}";
|
||||
} else {
|
||||
position = "null";
|
||||
}
|
||||
} else {
|
||||
position = "null";
|
||||
}
|
||||
|
||||
node["long_name"] = new JSONValue(tempNodeInfo->long_name);
|
||||
node["short_name"] = new JSONValue(tempNodeInfo->short_name);
|
||||
// mac_address dropped from NodeInfoLite as part of the slim refactor; emit zeros.
|
||||
node["mac_address"] = new JSONValue("00:00:00:00:00:00");
|
||||
node["hw_model"] = new JSONValue(tempNodeInfo->hw_model);
|
||||
if (!firstNode)
|
||||
out += ",";
|
||||
firstNode = false;
|
||||
|
||||
nodesArray.push_back(new JSONValue(node));
|
||||
// Alphabetical key order matches previous std::map-based output.
|
||||
out += "{\"hw_model\":";
|
||||
out += jsonNum(tempNodeInfo->hw_model);
|
||||
out += ",\"id\":";
|
||||
out += jsonEscape(id);
|
||||
out += ",\"last_heard\":";
|
||||
out += jsonNum((int)tempNodeInfo->last_heard);
|
||||
out += ",\"long_name\":";
|
||||
out += jsonEscape(tempNodeInfo->long_name);
|
||||
out += ",\"mac_address\":";
|
||||
out += jsonEscape("00:00:00:00:00:00");
|
||||
out += ",\"position\":";
|
||||
out += position;
|
||||
out += ",\"short_name\":";
|
||||
out += jsonEscape(tempNodeInfo->short_name);
|
||||
out += ",\"snr\":";
|
||||
out += jsonNum(tempNodeInfo->snr);
|
||||
out += ",\"via_mqtt\":";
|
||||
out += jsonEscape(BoolToString(nodeInfoLiteViaMqtt(tempNodeInfo)));
|
||||
out += "}";
|
||||
}
|
||||
tempNodeInfo = nodeDB->readNextMeshNode(readIndex);
|
||||
}
|
||||
|
||||
// collect data to inner data object
|
||||
JSONObject jsonObjInner;
|
||||
jsonObjInner["nodes"] = new JSONValue(nodesArray);
|
||||
|
||||
// create json output structure
|
||||
JSONObject jsonObjOuter;
|
||||
jsonObjOuter["data"] = new JSONValue(jsonObjInner);
|
||||
jsonObjOuter["status"] = new JSONValue("ok");
|
||||
// serialize and write it to the stream
|
||||
JSONValue *value = new JSONValue(jsonObjOuter);
|
||||
std::string jsonString = value->Stringify();
|
||||
res->print(jsonString.c_str());
|
||||
delete value;
|
||||
out += "]},\"status\":\"ok\"}";
|
||||
res->print(out.c_str());
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -897,20 +977,28 @@ void handleScanNetworks(HTTPRequest *req, HTTPResponse *res)
|
||||
|
||||
int n = WiFi.scanNetworks();
|
||||
|
||||
// build list of network objects
|
||||
JSONArray networkObjs;
|
||||
std::string out = "{\"data\":[";
|
||||
bool firstNet = true;
|
||||
if (n > 0) {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
char ssidArray[50];
|
||||
// The previous implementation pre-escaped quotes before handing
|
||||
// the value to the JSON serializer; preserve that (byte-compatible
|
||||
// even if it double-encodes a quote) so existing clients are not
|
||||
// affected by this refactor.
|
||||
String ssidString = String(WiFi.SSID(i));
|
||||
ssidString.replace("\"", "\\\"");
|
||||
ssidString.toCharArray(ssidArray, 50);
|
||||
|
||||
if (WiFi.encryptionType(i) != WIFI_AUTH_OPEN) {
|
||||
JSONObject thisNetwork;
|
||||
thisNetwork["ssid"] = new JSONValue(ssidArray);
|
||||
thisNetwork["rssi"] = new JSONValue(int(WiFi.RSSI(i)));
|
||||
networkObjs.push_back(new JSONValue(thisNetwork));
|
||||
if (!firstNet)
|
||||
out += ",";
|
||||
firstNet = false;
|
||||
out += "{\"rssi\":";
|
||||
out += jsonNum((int)WiFi.RSSI(i));
|
||||
out += ",\"ssid\":";
|
||||
out += jsonEscape(ssidArray);
|
||||
out += "}";
|
||||
}
|
||||
// Yield some cpu cycles to IP stack.
|
||||
// This is important in case the list is large and it takes us time to return
|
||||
@@ -918,16 +1006,7 @@ void handleScanNetworks(HTTPRequest *req, HTTPResponse *res)
|
||||
yield();
|
||||
}
|
||||
}
|
||||
|
||||
// build output structure
|
||||
JSONObject jsonObjOuter;
|
||||
jsonObjOuter["data"] = new JSONValue(networkObjs);
|
||||
jsonObjOuter["status"] = new JSONValue("ok");
|
||||
|
||||
// serialize and write it to the stream
|
||||
JSONValue *value = new JSONValue(jsonObjOuter);
|
||||
std::string jsonString = value->Stringify();
|
||||
res->print(jsonString.c_str());
|
||||
delete value;
|
||||
out += "],\"status\":\"ok\"}";
|
||||
res->print(out.c_str());
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -109,6 +109,15 @@ static inline int get_max_num_nodes()
|
||||
#define HAS_TRAFFIC_MANAGEMENT 0
|
||||
#endif
|
||||
|
||||
// HopScalingModule - variable hop module: dynamically adjusts broadcast hop_limit based on mesh density
|
||||
// Enable per-variant by defining HAS_VARIABLE_HOPS=1 in variant.h
|
||||
#ifdef ARCH_STM32WL
|
||||
#define HAS_VARIABLE_HOPS 0
|
||||
#endif
|
||||
#ifndef HAS_VARIABLE_HOPS
|
||||
#define HAS_VARIABLE_HOPS 1
|
||||
#endif
|
||||
|
||||
// Cache size for traffic management (number of nodes to track)
|
||||
// Can be overridden per-variant based on available memory
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
|
||||
@@ -66,4 +66,10 @@ inline uint32_t pow_of_2(uint32_t n)
|
||||
return 1 << n;
|
||||
}
|
||||
|
||||
/// Returns true if n is a power of two (n >= 1).
|
||||
template <typename T> constexpr bool is_pow_of_2(T n)
|
||||
{
|
||||
return n >= T(1) && (n & (n - T(1))) == T(0);
|
||||
}
|
||||
|
||||
#define IS_ONE_OF(item, ...) isOneOf(item, sizeof((int[]){__VA_ARGS__}) / sizeof(int), __VA_ARGS__)
|
||||
|
||||
@@ -1463,6 +1463,10 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
|
||||
}
|
||||
channels.onConfigChanged();
|
||||
|
||||
if (strcmp(p.call_sign, "N0CALL") == 0) {
|
||||
config.lora.tx_enabled = false;
|
||||
}
|
||||
|
||||
service->reloadOwner(false);
|
||||
saveChanges(SEGMENT_CONFIG | SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
||||
private:
|
||||
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
|
||||
void handleSetChannel();
|
||||
|
||||
public:
|
||||
void handleSetHamMode(const meshtastic_HamParameters &req);
|
||||
|
||||
private:
|
||||
void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg);
|
||||
void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent);
|
||||
void reboot(int32_t seconds);
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
#include "HopScalingModule.h"
|
||||
#include "SafeFile.h"
|
||||
#include "meshUtils.h"
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
|
||||
#include "FSCommon.h"
|
||||
#include "NodeDB.h"
|
||||
#include "SPILock.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace
|
||||
{
|
||||
// Module scheduling
|
||||
constexpr uint32_t INITIAL_DELAY_MS = 30 * 1000UL; // Startup grace period before first run
|
||||
constexpr uint32_t RUN_INTERVAL_MS = 5 * 60 * 1000UL; // Emit micro-summary every 5 minutes
|
||||
// RUNS_PER_HOUR is a public class constant in HopScalingModule.h
|
||||
|
||||
// Persistence
|
||||
// Note: this only needs incrementing if the published arrangement changes. For testing purposes, or prior to widespread release,
|
||||
// it can stay the same even if the internal layout changes.
|
||||
constexpr uint32_t HISTOGRAM_STATE_MAGIC = 0x48535432; // 'HST2' — layout v2
|
||||
constexpr uint8_t HISTOGRAM_STATE_VERSION = 1;
|
||||
constexpr const char *HISTOGRAM_STATE_FILE = "/prefs/hopScalingState.bin";
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct PersistedHistogram {
|
||||
uint32_t magic;
|
||||
uint8_t version;
|
||||
uint8_t samplingDenominator;
|
||||
uint8_t filteringDenominator;
|
||||
uint8_t filterDenomHoldRollsRemaining; // rollHour() calls remaining in the hold; 0 when expired/not active
|
||||
uint16_t hashSeed;
|
||||
Record entries[HopScalingModule::CAPACITY]; // full 512-byte array; count derived on load
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
} // namespace
|
||||
|
||||
HopScalingModule *hopScalingModule;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
HopScalingModule::HopScalingModule() : concurrency::OSThread("HopScaling")
|
||||
{
|
||||
clear();
|
||||
loadFromDisk();
|
||||
setIntervalFromNow(INITIAL_DELAY_MS);
|
||||
}
|
||||
|
||||
void HopScalingModule::clear()
|
||||
{
|
||||
memset(entries, 0, sizeof(entries));
|
||||
count = 0;
|
||||
samplingDenominator = DENOM_MIN;
|
||||
filteringDenominator = DENOM_MIN;
|
||||
filteringDenomHoldRollsRemaining = 0;
|
||||
lastPerHopCounts = {};
|
||||
lastSuggestedHop = MAX_HOP;
|
||||
lastPoliteNumer = POLITENESS_DEFAULT;
|
||||
lastTrendStats = {};
|
||||
memset(denominatorHistory, DENOM_MIN, sizeof(denominatorHistory));
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
hashSeed = static_cast<uint16_t>(random());
|
||||
#else
|
||||
hashSeed = 0; // deterministic in unit tests
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void HopScalingModule::saveToDisk() const
|
||||
{
|
||||
#ifdef FSCom
|
||||
FSCom.mkdir("/prefs");
|
||||
PersistedHistogram state{};
|
||||
state.magic = HISTOGRAM_STATE_MAGIC;
|
||||
state.version = HISTOGRAM_STATE_VERSION;
|
||||
state.samplingDenominator = samplingDenominator;
|
||||
state.filteringDenominator = filteringDenominator;
|
||||
state.filterDenomHoldRollsRemaining = filteringDenomHoldRollsRemaining;
|
||||
state.hashSeed = hashSeed;
|
||||
// Save all CAPACITY slots; count is reconstructed on load by scanning seenHoursAgo.
|
||||
memcpy(state.entries, entries, sizeof(state.entries));
|
||||
auto file = SafeFile(HISTOGRAM_STATE_FILE, true);
|
||||
const size_t written = file.write(reinterpret_cast<const uint8_t *>(&state), sizeof(state));
|
||||
if (file.close() && written == sizeof(state)) {
|
||||
LOG_DEBUG("[HOPSCALE] Saved: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
|
||||
filteringDenominator, state.filterDenomHoldRollsRemaining);
|
||||
} else {
|
||||
LOG_WARN("[HOPSCALE] Failed to write %s (%u of %u bytes)", HISTOGRAM_STATE_FILE, static_cast<unsigned>(written),
|
||||
static_cast<unsigned>(sizeof(state)));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void HopScalingModule::loadFromDisk()
|
||||
{
|
||||
#ifdef FSCom
|
||||
concurrency::LockGuard g(spiLock);
|
||||
auto file = FSCom.open(HISTOGRAM_STATE_FILE, FILE_O_READ);
|
||||
if (!file)
|
||||
return;
|
||||
PersistedHistogram state{};
|
||||
const bool readOk = (file.read(reinterpret_cast<uint8_t *>(&state), sizeof(state)) == sizeof(state));
|
||||
file.close();
|
||||
// Validate magic, version, denom range, denom power-of-two invariant, and hold counter.
|
||||
if (!readOk || state.magic != HISTOGRAM_STATE_MAGIC || state.version != HISTOGRAM_STATE_VERSION ||
|
||||
state.samplingDenominator < DENOM_MIN || state.samplingDenominator > DENOM_MAX ||
|
||||
state.filteringDenominator < state.samplingDenominator || state.filteringDenominator > DENOM_MAX ||
|
||||
!is_pow_of_2(state.samplingDenominator) || !is_pow_of_2(state.filteringDenominator) ||
|
||||
state.filterDenomHoldRollsRemaining > FILTER_DENOM_HOLD_ROLLS) {
|
||||
LOG_DEBUG("[HOPSCALE] No valid persisted state (magic=%08x ver=%u samp=%u filt=%u hold=%u), starting fresh", state.magic,
|
||||
state.version, state.samplingDenominator, state.filteringDenominator, state.filterDenomHoldRollsRemaining);
|
||||
return;
|
||||
}
|
||||
// Derive count by scanning: active entries have seenHoursAgo != 0; pack them to the front.
|
||||
|
||||
uint8_t restored = 0;
|
||||
for (uint8_t i = 0; i < CAPACITY && restored < CAPACITY; i++) {
|
||||
if (state.entries[i].seenHoursAgo != 0u) {
|
||||
entries[restored++] = state.entries[i];
|
||||
}
|
||||
}
|
||||
count = restored;
|
||||
samplingDenominator = state.samplingDenominator;
|
||||
filteringDenominator = state.filteringDenominator;
|
||||
filteringDenomHoldRollsRemaining = state.filterDenomHoldRollsRemaining;
|
||||
// denominatorHistory can't be recovered; initialise all slots to filteringDenominator so
|
||||
// the first few post-reboot scaledPerHour values use a safe (slightly conservative) multiplier.
|
||||
memset(denominatorHistory, filteringDenominator, sizeof(denominatorHistory));
|
||||
hashSeed = state.hashSeed;
|
||||
LOG_INFO("[HOPSCALE] Restored: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
|
||||
filteringDenominator, state.filterDenomHoldRollsRemaining);
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void HopScalingModule::samplePacketForHistogram(uint32_t nodeId, uint8_t hopCount)
|
||||
{
|
||||
const uint16_t hash = hashNodeId(nodeId);
|
||||
|
||||
if (!passesFilter(hash, samplingDenominator))
|
||||
return;
|
||||
|
||||
hopCount = std::min(hopCount, MAX_HOP);
|
||||
|
||||
// Update an existing entry
|
||||
Record *entry = nullptr;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
if (entries[i].nodeHash == hash) {
|
||||
entry = &entries[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (entry) {
|
||||
entry->hops_away = hopCount;
|
||||
markCurrentHour(*entry);
|
||||
return;
|
||||
}
|
||||
|
||||
// New node: trim if necessary before allocating a slot
|
||||
if (getFillPercentage() >= FILL_HIGH_PCT) {
|
||||
trimIfNeeded();
|
||||
}
|
||||
|
||||
if (count < CAPACITY) {
|
||||
entries[count].nodeHash = hash;
|
||||
entries[count].hops_away = hopCount;
|
||||
entries[count].seenHoursAgo = 1u; // mark current hour
|
||||
count++;
|
||||
} else {
|
||||
LOG_WARN("[HOPSCALE] Histogram full at samp=1/%u (DENOM_MAX=%u); dropping node hash=0x%04x; hop recommendation may be "
|
||||
"skewed!!!",
|
||||
samplingDenominator, DENOM_MAX, hash);
|
||||
}
|
||||
}
|
||||
|
||||
void HopScalingModule::rollHour()
|
||||
{
|
||||
// Advance denominatorHistory before the tally so each slot h holds the filteringDenominator
|
||||
// that was active when seenHoursAgo bit h was set. hourlyRaw[h] is then gated per-slot by
|
||||
// denominatorHistory[h], giving a correct population estimate for each historical hour even
|
||||
// when filteringDenominator changes between rolls. Scale-up backfills the entire array so
|
||||
// the invariant holds retroactively (see trimIfNeeded()).
|
||||
for (uint8_t h = 12; h > 0; h--)
|
||||
denominatorHistory[h] = denominatorHistory[h - 1];
|
||||
denominatorHistory[0] = filteringDenominator;
|
||||
|
||||
// 1. Tally per-hop counts and per-slot hourly activity in one pass.
|
||||
// hourlyRaw[h]: gated per-slot by denominatorHistory[h] so the raw count and its
|
||||
// multiplier are always consistent, even across filteringDenominator transitions.
|
||||
// counts.*: gated uniformly by the current filteringDenominator for a consistent
|
||||
// population estimate used by the hop-walk recommendation (step 2).
|
||||
PerHopCounts counts{};
|
||||
uint16_t hourlyRaw[13] = {};
|
||||
uint16_t trendNewThisHour = 0;
|
||||
uint16_t trendReturning = 0;
|
||||
uint16_t trendLapsed = 0;
|
||||
uint16_t trendOlderThan4h = 0;
|
||||
uint16_t trendAgingOut = 0;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
const uint16_t hash = entries[i].nodeHash;
|
||||
const uint32_t seen = entries[i].seenHoursAgo;
|
||||
|
||||
// Per-slot hourly activity: gate each slot by its own denominator.
|
||||
for (uint8_t h = 0; h < 13; h++) {
|
||||
if ((seen & (1u << h)) && passesFilter(hash, denominatorHistory[h]))
|
||||
hourlyRaw[h]++;
|
||||
}
|
||||
|
||||
// Hop counts and trend stats: uniform current-denominator gate.
|
||||
if (!passesFilter(hash, filteringDenominator))
|
||||
continue;
|
||||
|
||||
if (seenInLast13h(entries[i])) {
|
||||
counts.perHop[entries[i].hops_away]++;
|
||||
counts.total++;
|
||||
}
|
||||
const bool heardThisHour = (seen & 1u) != 0u;
|
||||
const bool heardLastHour = (seen & 2u) != 0u;
|
||||
const bool hasOlderHistory = (seen >> 1u) != 0u;
|
||||
const bool recentlySilent = (seen & 0xFu) == 0u;
|
||||
if (heardThisHour && !hasOlderHistory)
|
||||
trendNewThisHour++;
|
||||
else if (heardThisHour && hasOlderHistory)
|
||||
trendReturning++;
|
||||
if (!heardThisHour && heardLastHour)
|
||||
trendLapsed++;
|
||||
if (recentlySilent && (seen & 0x1FF0u) != 0u)
|
||||
trendOlderThan4h++;
|
||||
if (seen == (1u << 12u))
|
||||
trendAgingOut++;
|
||||
}
|
||||
lastPerHopCounts = counts;
|
||||
|
||||
// 1b. Compute politeness factor from the 0-2 h vs 1-3 h activity ratio.
|
||||
{
|
||||
const uint32_t recent = static_cast<uint32_t>(hourlyRaw[0]) + hourlyRaw[1];
|
||||
const uint32_t older = static_cast<uint32_t>(hourlyRaw[1]) + hourlyRaw[2];
|
||||
if (older > 1 && recent > 1) {
|
||||
const uint32_t r = static_cast<uint32_t>(recent) * ACTIVITY_WEIGHT_SCALE;
|
||||
const uint32_t o = static_cast<uint32_t>(older);
|
||||
if (r < o * ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER)
|
||||
lastPoliteNumer = POLITENESS_GENEROUS;
|
||||
else if (r > o * ACTIVITY_WEIGHT_STRICT_MIN_NUMER)
|
||||
lastPoliteNumer = POLITENESS_STRICT;
|
||||
else
|
||||
lastPoliteNumer = POLITENESS_DEFAULT;
|
||||
} else {
|
||||
lastPoliteNumer = POLITENESS_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
// 1c. Scale and cache trend stats (denominatorHistory already advanced above).
|
||||
{
|
||||
MeshTrendStats t{};
|
||||
for (uint8_t h = 0; h < 13; h++) {
|
||||
const uint32_t s = static_cast<uint32_t>(hourlyRaw[h]) * denominatorHistory[h];
|
||||
t.scaledPerHour[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
|
||||
}
|
||||
auto scale = [&](uint16_t raw) -> uint16_t {
|
||||
return static_cast<uint16_t>(std::min<uint32_t>(static_cast<uint32_t>(raw) * filteringDenominator, UINT16_MAX));
|
||||
};
|
||||
t.newThisHour = scale(trendNewThisHour);
|
||||
t.returningThisHour = scale(trendReturning);
|
||||
t.lapsedSinceLastHour = scale(trendLapsed);
|
||||
t.olderThan4h = scale(trendOlderThan4h);
|
||||
t.agingOut = scale(trendAgingOut);
|
||||
lastTrendStats = t;
|
||||
}
|
||||
|
||||
// 2. Walk scaled hop buckets to produce a hop-limit recommendation.
|
||||
// effectiveMin: walk threshold — first hop whose cumulative count reaches this.
|
||||
// effectiveMax: ceiling on the one-hop extension check with GENEROUS politeness.
|
||||
const uint16_t effectiveMin = TARGET_AFFECTED_NODES;
|
||||
const uint16_t effectiveMax = MAX_TARGET_NODES;
|
||||
uint8_t suggested = MAX_HOP;
|
||||
if (counts.total > 0) {
|
||||
uint32_t cumulative = 0;
|
||||
for (uint8_t hop = 0; hop <= MAX_HOP; hop++) {
|
||||
cumulative += static_cast<uint32_t>(counts.perHop[hop]) * filteringDenominator;
|
||||
if (cumulative >= effectiveMin) {
|
||||
suggested = hop;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (suggested < MAX_HOP) {
|
||||
const uint32_t atNext = static_cast<uint32_t>(counts.perHop[suggested + 1]) * filteringDenominator;
|
||||
// politeLimit = effectiveMin + gap * politeNumer / POLITENESS_DENOM
|
||||
// Multiply both sides by POLITENESS_DENOM to stay in integers.
|
||||
const uint32_t gap = static_cast<uint32_t>(effectiveMax) - static_cast<uint32_t>(effectiveMin);
|
||||
if ((cumulative + atNext) * POLITENESS_DENOM <=
|
||||
static_cast<uint32_t>(effectiveMin) * POLITENESS_DENOM + gap * lastPoliteNumer) {
|
||||
suggested++;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSuggestedHop = suggested;
|
||||
|
||||
// 3. Log scaled per-hop counts and recommendation.
|
||||
{
|
||||
uint16_t scaled[MAX_HOP + 1];
|
||||
for (uint8_t h = 0; h <= MAX_HOP; h++) {
|
||||
const uint32_t s = static_cast<uint32_t>(counts.perHop[h]) * filteringDenominator;
|
||||
scaled[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
|
||||
}
|
||||
const uint32_t scaledTotal = static_cast<uint32_t>(counts.total) * filteringDenominator;
|
||||
memcpy(lastScaledPerHop, scaled, sizeof(lastScaledPerHop));
|
||||
LOG_INFO("[HOPSCALE] rollHour: entries=%u/128 samp=1/%u filt=1/%u counted=%u est=%u suggestedHop=%u polite=%u/4", count,
|
||||
samplingDenominator, filteringDenominator, counts.total, static_cast<unsigned>(scaledTotal), suggested,
|
||||
lastPoliteNumer);
|
||||
|
||||
const auto &ts = lastTrendStats;
|
||||
LOG_INFO("[HOPSCALE] scaledSeenPerHour (h0=now): [%u %u %u %u %u %u %u %u %u %u %u %u %u]", ts.scaledPerHour[0],
|
||||
ts.scaledPerHour[1], ts.scaledPerHour[2], ts.scaledPerHour[3], ts.scaledPerHour[4], ts.scaledPerHour[5],
|
||||
ts.scaledPerHour[6], ts.scaledPerHour[7], ts.scaledPerHour[8], ts.scaledPerHour[9], ts.scaledPerHour[10],
|
||||
ts.scaledPerHour[11], ts.scaledPerHour[12]);
|
||||
LOG_INFO("[HOPSCALE] trend: new=%u returning=%u lapsed=%u olderThan4h=%u agingOut=%u", ts.newThisHour,
|
||||
ts.returningThisHour, ts.lapsedSinceLastHour, ts.olderThan4h, ts.agingOut);
|
||||
}
|
||||
|
||||
// 4. Scale-down check: if fewer than FILL_LOW_PCT% of capacity pass the filteringDenominator
|
||||
// gate and are active, halve samplingDenominator to admit more nodes.
|
||||
// Note: during a filteringDenominator hold period, lowering samplingDenominator does not
|
||||
// immediately improve counts.total (new admissions don't pass the elevated
|
||||
// filteringDenominator). On a genuinely quieting mesh this check can therefore fire on
|
||||
// consecutive hours, cascading samplingDenominator toward DENOM_MIN. This is intentional:
|
||||
// rapid re-admission allows quick recovery if the mesh returns. The hop recommendation
|
||||
// stays conservative (MAX_HOP) throughout because filteringDenominator remains elevated;
|
||||
// step 5 below re-synchronises the denominators once the hold expires.
|
||||
if (counts.total * 100u < static_cast<uint32_t>(CAPACITY) * FILL_LOW_PCT) {
|
||||
if (samplingDenominator > DENOM_MIN) {
|
||||
samplingDenominator = static_cast<uint8_t>(samplingDenominator / 2u);
|
||||
LOG_INFO("[HOPSCALE] Scale-down: sampling denom halved to %u (filter denom=%u)", samplingDenominator,
|
||||
filteringDenominator);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Tick down the hold counter; once it reaches zero, halve filteringDenominator toward
|
||||
// samplingDenominator once per rollHour() (= once per hour) rather than a single jump:
|
||||
// avoids a sudden large change in the hop-walk count when samplingDenominator cascaded
|
||||
// down significantly during the hold period. No new hold is placed on each step — the
|
||||
// 13-roll hold already guaranteed that re-admitted nodes have full seenHoursAgo history;
|
||||
// further pacing is provided naturally by the 1-step-per-hour rate. denominatorHistory
|
||||
// is updated automatically by the shift at the top of rollHour(), so no backfill here.
|
||||
if (filteringDenominator > samplingDenominator) {
|
||||
if (filteringDenomHoldRollsRemaining > 0)
|
||||
filteringDenomHoldRollsRemaining--;
|
||||
if (filteringDenomHoldRollsRemaining == 0) {
|
||||
const uint8_t stepped = static_cast<uint8_t>(filteringDenominator / 2u);
|
||||
filteringDenominator = (stepped > samplingDenominator) ? stepped : samplingDenominator;
|
||||
LOG_INFO("[HOPSCALE] Filter denom stepped to %u (samp=1/%u)", filteringDenominator, samplingDenominator);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Shift all seen bitmaps left by one slot (opens a fresh slot for the new hour).
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
rollSeenBits(entries[i]);
|
||||
}
|
||||
|
||||
if (histogramRollCount < 255)
|
||||
histogramRollCount++;
|
||||
|
||||
saveToDisk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void HopScalingModule::trimIfNeeded()
|
||||
{
|
||||
// Step 1: evict stale entries (not seen in any of the past 13 hours).
|
||||
uint8_t newCount = 0;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
if (seenInLast13h(entries[i])) {
|
||||
if (i != newCount) {
|
||||
entries[newCount] = entries[i];
|
||||
}
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
count = newCount;
|
||||
|
||||
// Step 2: if still too full, double the sampling denominator and remove non-matching entries.
|
||||
if (getFillPercentage() >= FILL_HIGH_PCT && samplingDenominator < DENOM_MAX) {
|
||||
samplingDenominator = static_cast<uint8_t>(
|
||||
std::min<uint16_t>(static_cast<uint16_t>(samplingDenominator) * 2u, static_cast<uint16_t>(DENOM_MAX)));
|
||||
filteringDenominator = std::max(filteringDenominator, samplingDenominator);
|
||||
filteringDenomHoldRollsRemaining = FILTER_DENOM_HOLD_ROLLS;
|
||||
// Raise any denominatorHistory slot that is below the new filteringDenominator.
|
||||
// Slots already above it (recorded during a prior scale-up that hasn't fully stepped
|
||||
// down yet) are left untouched: eviction at samplingDenominator retains exactly those
|
||||
// entries, so the old higher gate remains accurate for those historical hours.
|
||||
// Slots below the new value must be raised because the eviction removed entries that
|
||||
// had been admitted at the looser old gate — the remaining entries represent a 1/N
|
||||
// subsample where N is the new filteringDenominator, not the old smaller value.
|
||||
for (uint8_t h = 0; h < 13; h++)
|
||||
denominatorHistory[h] = std::max(denominatorHistory[h], filteringDenominator);
|
||||
LOG_INFO("[HOPSCALE] Scale-up: samp denom doubled to %u (filt=%u)", samplingDenominator, filteringDenominator);
|
||||
|
||||
newCount = 0;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
if (passesFilter(entries[i].nodeHash, samplingDenominator)) {
|
||||
if (i != newCount) {
|
||||
entries[newCount] = entries[i];
|
||||
}
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
count = newCount;
|
||||
}
|
||||
}
|
||||
|
||||
void HopScalingModule::logStatusReport(bool didHourlyUpdate) const
|
||||
{
|
||||
const bool histActive = (histogramRollCount > 0 && count > 0);
|
||||
const auto &histCounts = lastPerHopCounts;
|
||||
const uint8_t runsRemaining = didHourlyUpdate ? RUNS_PER_HOUR : (RUNS_PER_HOUR - runsSinceLastHourlyUpdate);
|
||||
const uint8_t minsUntilRollover = runsRemaining * (RUN_INTERVAL_MS / (60 * 1000UL));
|
||||
|
||||
LOG_INFO("[HOPSCALE] hop=%u histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u lastCounted=%u polite=%u/4 "
|
||||
"nextRoll=%umin",
|
||||
lastRequiredHop, histActive ? 1u : 0u, getFillPercentage(), samplingDenominator, filteringDenominator, count,
|
||||
histCounts.total, lastPoliteNumer, minsUntilRollover);
|
||||
|
||||
LOG_INFO("[HOPSCALE] nodes perHop: [%u %u %u %u %u %u %u %u]", histCounts.perHop[0], histCounts.perHop[1],
|
||||
histCounts.perHop[2], histCounts.perHop[3], histCounts.perHop[4], histCounts.perHop[5], histCounts.perHop[6],
|
||||
histCounts.perHop[7]);
|
||||
LOG_INFO("[HOPSCALE] last scaled perHop: [%u %u %u %u %u %u %u %u]", lastScaledPerHop[0], lastScaledPerHop[1],
|
||||
lastScaledPerHop[2], lastScaledPerHop[3], lastScaledPerHop[4], lastScaledPerHop[5], lastScaledPerHop[6],
|
||||
lastScaledPerHop[7]);
|
||||
}
|
||||
|
||||
int32_t HopScalingModule::runOnce()
|
||||
{
|
||||
const bool isFirstRun = !hasCompletedInitialRun;
|
||||
bool didHourlyUpdate = false;
|
||||
|
||||
if (isFirstRun) {
|
||||
hasCompletedInitialRun = true;
|
||||
runsSinceLastHourlyUpdate = 0;
|
||||
didHourlyUpdate = true;
|
||||
} else {
|
||||
runsSinceLastHourlyUpdate++;
|
||||
if (runsSinceLastHourlyUpdate >= RUNS_PER_HOUR) {
|
||||
runsSinceLastHourlyUpdate = 0;
|
||||
didHourlyUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didHourlyUpdate && !isFirstRun) {
|
||||
rollHour();
|
||||
}
|
||||
|
||||
if (didHourlyUpdate) {
|
||||
uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX;
|
||||
// Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops,
|
||||
// SENSOR reaches at least 1, so these reporting roles remain reachable even
|
||||
// on a dense mesh where the histogram recommends a lower hop count.
|
||||
uint8_t roleFloor = 0;
|
||||
switch (config.device.role) {
|
||||
case meshtastic_Config_DeviceConfig_Role_TRACKER:
|
||||
case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER:
|
||||
roleFloor = 2;
|
||||
break;
|
||||
case meshtastic_Config_DeviceConfig_Role_SENSOR:
|
||||
roleFloor = 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
lastRequiredHop = std::max(suggested, roleFloor);
|
||||
}
|
||||
|
||||
logStatusReport(didHourlyUpdate);
|
||||
|
||||
return RUN_INTERVAL_MS;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,351 @@
|
||||
#pragma once
|
||||
|
||||
#include "MeshTypes.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh/Default.h"
|
||||
#include "mesh/mesh-pb-constants.h"
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
|
||||
/**
|
||||
* HopScalingModule: Sampled hop-distance histogram for mesh-aware hop limit recommendations.
|
||||
*
|
||||
* Memory layout: 512 bytes total (128 entries × 4 bytes/entry, no padding)
|
||||
* - 16-bit XOR-fold hash of node ID
|
||||
* - 3-bit hops away (0–7)
|
||||
* - 13-bit hourly seen bitmap
|
||||
* All three fields are packed into a single 32-bit Record; sizeof(Record) == 4.
|
||||
*
|
||||
* Sampling:
|
||||
* - A node is added only when passesFilter(hashNodeId(nodeId), samplingDenominator),
|
||||
* i.e. (hash16(nodeId) & (samplingDenominator – 1)) == 0 (hash-space subsample, not raw ID)
|
||||
* - samplingDenominator starts at 1 (sample all), doubles when the list exceeds FILL_HIGH_PCT
|
||||
* - filteringDenominator tracks samplingDenominator upward immediately but does not drop back
|
||||
* down until FILTER_DENOM_HOLD_MS (13 h) have elapsed since the last scale-up
|
||||
*
|
||||
* Hourly rollover (rollHour()):
|
||||
* - Summarises per-hop node counts for entries matching filteringDenominator and seen in the
|
||||
* last 13 hours
|
||||
* - Scales each hop bucket by filteringDenominator and walks the buckets to recommend a hop
|
||||
* limit, matching the same algorithm used in HopScalingModule
|
||||
* - Shifts the 13-bit seen bitmap left by one slot to open a fresh slot for the new hour;
|
||||
* nodes not seen in 13 consecutive hours have all seen bits cleared (stale)
|
||||
* - Checks for scale-down: if fewer than FILL_LOW_PCT of capacity pass filteringDenominator,
|
||||
* samplingDenominator is halved (filteringDenominator is held until the 13-h lock expires)
|
||||
*
|
||||
* Thread-safety: all access is single-threaded via the main loop cooperative scheduler.
|
||||
*/
|
||||
|
||||
struct Record {
|
||||
uint32_t nodeHash : 16;
|
||||
uint32_t hops_away : 3;
|
||||
uint32_t seenHoursAgo : 13;
|
||||
};
|
||||
static_assert(sizeof(Record) == 4);
|
||||
|
||||
class HopScalingModule : private concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
// -----------------------------------------------------------------------
|
||||
// Capacity and memory layout
|
||||
// -----------------------------------------------------------------------
|
||||
static constexpr size_t CAPACITY = 128;
|
||||
static constexpr size_t ENTRY_BYTES = sizeof(Record);
|
||||
static constexpr size_t TOTAL_BYTES = CAPACITY * ENTRY_BYTES;
|
||||
|
||||
// Denominator limits (must be powers of 2)
|
||||
static constexpr uint8_t DENOM_MIN = 1;
|
||||
static constexpr uint8_t DENOM_MAX = 128;
|
||||
|
||||
static constexpr uint8_t MAX_HOP = 7;
|
||||
|
||||
// Fill-level thresholds (percent of CAPACITY)
|
||||
static constexpr uint8_t FILL_HIGH_PCT = 80;
|
||||
static constexpr uint8_t FILL_LOW_PCT = 20;
|
||||
|
||||
// How long filteringDenominator is held at an elevated level before it may drop.
|
||||
//
|
||||
// This value is deliberately equal to the seenHoursAgo window (13 hours / 13 bits).
|
||||
// Invariant: every entry that existed when a scale-up fired had seenHoursAgo != 0 at
|
||||
// that moment (trimIfNeeded() evicts stale entries before doubling the denominator),
|
||||
// so it remains seenInLast13h for at most 13 more rollHour() calls — exactly the
|
||||
// hold duration. That means entries from the scale-up event keep counts.total above
|
||||
// the scale-down threshold for the entire hold period under normal (active) mesh
|
||||
// conditions. On a genuinely quieting mesh the scale-down CAN fire before the hold
|
||||
// expires — each firing halves samplingDenominator but filteringDenominator stays
|
||||
// elevated, so the hop recommendation correctly stays conservative (MAX_HOP) while
|
||||
// the cascade runs. The cascade is bounded at DENOM_MIN (7 halvings from DENOM_MAX);
|
||||
// when the hold finally expires, step 5 of rollHour() halves filteringDenominator
|
||||
// once per hour (rather than jumping directly to samplingDenominator) until the two
|
||||
// converge, giving the hop-walk a gradual, 1-step-per-hour descent.
|
||||
static constexpr uint32_t FILTER_DENOM_HOLD_MS = 13UL * 60UL * 60UL * 1000UL; // 13 h (documentation only)
|
||||
// Number of rollHour() calls the hold spans — equals the seenHoursAgo window width.
|
||||
// filteringDenomHoldRollsRemaining is initialised to this value on scale-up and
|
||||
// decremented once per rollHour(); step-down begins when it reaches zero.
|
||||
static constexpr uint8_t FILTER_DENOM_HOLD_ROLLS = 13u;
|
||||
|
||||
// Hop-walk: target cumulative affected-node count when choosing a hop limit
|
||||
static constexpr uint16_t TARGET_AFFECTED_NODES = default_hop_scaling_min_target_nodes;
|
||||
|
||||
// Clamp bounds enforced on min_target_nodes / max_target_nodes
|
||||
static constexpr uint16_t MIN_TARGET_NODES_FLOOR = default_hop_scaling_min_target_nodes_floor;
|
||||
static constexpr uint16_t MAX_TARGET_NODES_CEILING = default_hop_scaling_max_target_nodes_ceiling;
|
||||
static constexpr uint16_t MAX_TARGET_NODES = default_hop_scaling_max_target_nodes;
|
||||
|
||||
// Politeness factors for the one-hop extension check in the hop walk.
|
||||
// Stored as integer numerators over POLITENESS_DENOM (4):
|
||||
// politeLimit = min + gap * politeNumer / POLITENESS_DENOM
|
||||
// STRICT → min + 25% of gap; DEFAULT → midpoint; GENEROUS → max
|
||||
static constexpr uint8_t POLITENESS_DENOM = 4u;
|
||||
static constexpr uint8_t POLITENESS_GENEROUS = 4u; // 4/4 = 1.00
|
||||
static constexpr uint8_t POLITENESS_DEFAULT = 2u; // 2/4 = 0.50
|
||||
static constexpr uint8_t POLITENESS_STRICT = 1u; // 1/4 = 0.25
|
||||
|
||||
// Activity weight thresholds (ratio of 0-2 h window vs 1-3 h window).
|
||||
// Cross-multiply form: recent * ACTIVITY_WEIGHT_SCALE vs older * threshold_numer.
|
||||
// GENEROUS if recent*10 < older*9 (ratio < 0.9); STRICT if recent*10 > older*12 (ratio > 1.2)
|
||||
static constexpr uint8_t ACTIVITY_WEIGHT_SCALE = 10u;
|
||||
static constexpr uint8_t ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER = 9u;
|
||||
static constexpr uint8_t ACTIVITY_WEIGHT_STRICT_MIN_NUMER = 12u;
|
||||
|
||||
// Scheduling: number of 5-minute runOnce() ticks that make up one hourly rollover
|
||||
static constexpr uint8_t RUNS_PER_HOUR = 12;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Types
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Per-hop node counts produced at each hourly rollover.
|
||||
struct PerHopCounts {
|
||||
uint16_t perHop[MAX_HOP + 1] = {};
|
||||
uint16_t total = 0;
|
||||
};
|
||||
|
||||
/// Mesh activity trend stats produced at each hourly rollover.
|
||||
/// All counts are scaled by filteringDenominator (i.e. estimated full-mesh population).
|
||||
///
|
||||
/// Bitmap interpretation (before the hourly shift): bit 0 = just-completed hour, bit 12 = 12 h ago.
|
||||
struct MeshTrendStats {
|
||||
/// Estimated node count per hour slot (h=0 is the just-completed hour, h=12 is 12 h ago).
|
||||
uint16_t scaledPerHour[13] = {};
|
||||
/// Nodes heard only this hour with no prior bitmap history — indicates new arrivals.
|
||||
uint16_t newThisHour = 0;
|
||||
/// Nodes heard this hour that also appeared in at least one older hour — stable regulars.
|
||||
uint16_t returningThisHour = 0;
|
||||
/// Nodes heard last hour but silent this hour — potential departures.
|
||||
uint16_t lapsedSinceLastHour = 0;
|
||||
/// Nodes absent from the last 4 hours but still present in some older hour (5–13 h) — quieting down.
|
||||
uint16_t olderThan4h = 0;
|
||||
/// Nodes whose only remaining history is the 13th hour (bit 12 only) — about to age out entirely.
|
||||
uint16_t agingOut = 0;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
HopScalingModule();
|
||||
~HopScalingModule() = default;
|
||||
|
||||
/// Reset all entries and state.
|
||||
void clear();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Record a received packet.
|
||||
/// Adds or updates an entry when passesFilter(hashNodeId(nodeId), samplingDenominator),
|
||||
/// i.e. when the 16-bit XOR-fold hash of the node ID falls in the 1/samplingDenominator
|
||||
/// subsample of the hash space. This is NOT a raw nodeId modulo check.
|
||||
/// Marks the current hour as seen and updates the stored hop count to the last observed value.
|
||||
/// Triggers a trim pass if the list exceeds FILL_HIGH_PCT after the insertion.
|
||||
void samplePacketForHistogram(uint32_t nodeId, uint8_t hopCount);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
uint8_t getLastRequiredHop() const { return lastRequiredHop; }
|
||||
uint8_t getEntryCount() const { return count; }
|
||||
uint8_t getFillPercentage() const { return static_cast<uint8_t>((static_cast<uint16_t>(count) * 100u) / CAPACITY); }
|
||||
uint8_t getSamplingDenominator() const { return samplingDenominator; }
|
||||
uint8_t getFilteringDenominator() const { return filteringDenominator; }
|
||||
float getPoliteness() const { return lastPoliteNumer / static_cast<float>(POLITENESS_DENOM); }
|
||||
const PerHopCounts &getLastPerHopCounts() const { return lastPerHopCounts; }
|
||||
uint8_t getLastSuggestedHop() const { return lastSuggestedHop; }
|
||||
const MeshTrendStats &getLastTrendStats() const { return lastTrendStats; }
|
||||
|
||||
// Compatibility accessors used by tests
|
||||
uint8_t getCompactHistogramEntryCount() const { return getEntryCount(); }
|
||||
uint8_t getCompactHistogramDenominator() const { return getSamplingDenominator(); }
|
||||
uint8_t getCompactHistogramFilterDenominator() const { return getFilteringDenominator(); }
|
||||
uint8_t getCompactHistogramSuggestedHop() const { return getLastSuggestedHop(); }
|
||||
size_t getCompactHistogramAllSampleCount() const { return getEntryCount(); }
|
||||
|
||||
/// Force both sampling and filtering denominators to a specific value.
|
||||
/// Intended for unit tests that need a deterministic starting denominator.
|
||||
void setSamplingDenominator(uint8_t d)
|
||||
{
|
||||
samplingDenominator = (d < DENOM_MIN) ? DENOM_MIN : (d > DENOM_MAX ? DENOM_MAX : d);
|
||||
filteringDenominator = samplingDenominator;
|
||||
filteringDenomHoldRollsRemaining = 0;
|
||||
}
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
// Writable from tests as HopScalingModule::s_testNowMs; drives nowMs() in PIO_UNIT_TESTING builds.
|
||||
inline static uint32_t s_testNowMs = 0;
|
||||
/// Override the per-session hash seed. Use in tests that need a specific sampling distribution.
|
||||
void setHashSeed(uint16_t seed) { hashSeed = seed; }
|
||||
uint16_t getHashSeed() const { return hashSeed; }
|
||||
/// Expose hashNodeId for tests that need to compute which node IDs pass a given denominator.
|
||||
uint16_t hashNodeIdPublic(uint32_t nodeId) const { return hashNodeId(nodeId); }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
int32_t runOnce() override;
|
||||
|
||||
private:
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
friend class HopScalingTestShim;
|
||||
#endif
|
||||
|
||||
/// Perform hourly rollover.
|
||||
/// 1. Tallies per-hop counts for entries matching filteringDenominator and seen in 13 h.
|
||||
/// 2. Walks the scaled hop buckets and returns the recommended hop limit.
|
||||
/// 3. Logs scaled per-hop counts and recommendation.
|
||||
/// 4. Checks for scale-down (< FILL_LOW_PCT of capacity pass filteringDenominator).
|
||||
/// 5. Decrements filteringDenomHoldRollsRemaining (if > 0); once it reaches zero, halves
|
||||
/// filteringDenominator once toward samplingDenominator per rollHour() call.
|
||||
/// 6. Shifts all seen bitmaps left by one hour slot.
|
||||
void rollHour();
|
||||
// -----------------------------------------------------------------------
|
||||
// Persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Persist the histogram state (entries, denominators, hold-timer) to flash.
|
||||
/// No-op on platforms without a filesystem. Performs a full delete-and-rewrite of
|
||||
/// the state file on each call; avoid calling more frequently than once per rollHour().
|
||||
void saveToDisk() const;
|
||||
|
||||
/// Restore histogram state from flash. Safe to call even when no file exists.
|
||||
/// Call once after construction, before the first rollHour(), to warm-start the
|
||||
/// histogram across reboots without waiting 13 hours for data to re-accumulate.
|
||||
/// The restored entries are available immediately for sampling, but the first
|
||||
/// rollHour() (triggered by the second runOnce() tick) is needed before a warm-start
|
||||
/// recommendation replaces the HOP_MAX boot default.
|
||||
void loadFromDisk();
|
||||
|
||||
/// Remove stale entries (seen-bits all zero) and, if the list is still crowded,
|
||||
/// double samplingDenominator and filteringDenominator and remove non-matching entries.
|
||||
void trimIfNeeded();
|
||||
|
||||
void logStatusReport(bool didHourlyUpdate) const;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Histogram storage
|
||||
// -----------------------------------------------------------------------
|
||||
Record entries[CAPACITY] = {};
|
||||
uint8_t count = 0;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Denominator state
|
||||
//
|
||||
// Two separate denominators control two distinct gates:
|
||||
//
|
||||
// samplingDenominator — admission gate. A node is added/updated only when
|
||||
// passesFilter(hash, samplingDenominator). Lower value = more permissive =
|
||||
// more nodes enter = represents recent mesh state.
|
||||
//
|
||||
// filteringDenominator — counting gate. The hop-walk tally in rollHour() only
|
||||
// counts entries that pass passesFilter(hash, filteringDenominator). It moves
|
||||
// up with samplingDenominator immediately (scale-up) but is held at the
|
||||
// elevated value for FILTER_DENOM_HOLD_MS (13 h) after any scale-up before it
|
||||
// may drop back down (scale-down).
|
||||
//
|
||||
// Why the estimate is invariant: passesFilter uses a hash-based uniform subsample.
|
||||
// For any two powers-of-two denominators D ≤ F, the fraction of D-sampled entries
|
||||
// that also pass F is exactly D/F. Therefore:
|
||||
// raw_count × F = (total × D/F) × F = total × D
|
||||
// The population estimate is the same whether we count with D or with F.
|
||||
// The hold period is not about accuracy — it is about stability: it prevents the
|
||||
// hop recommendation from reacting to recently-admitted nodes that have not yet
|
||||
// accumulated enough seenHoursAgo history to be statistically reliable.
|
||||
//
|
||||
// denominatorHistory[h] — the filteringDenominator used to both gate and scale
|
||||
// hourlyRaw[h]. Invariant: denominatorHistory[h] always equals the
|
||||
// filteringDenominator that was active when seenHoursAgo bit h was set.
|
||||
// rollHour() advances the array at the very start (before the tally loop), then
|
||||
// gates hourlyRaw[h] per-slot by denominatorHistory[h] — each slot's raw count
|
||||
// and multiplier are therefore always consistent, even when filteringDenominator
|
||||
// changes between rolls (e.g. hold expiry). On scale-up (trimIfNeeded()), the
|
||||
// entire array is backfilled uniformly with the new filteringDenominator to
|
||||
// preserve the invariant retroactively for all 13 slots. Initialised to
|
||||
// DENOM_MIN (1); scaledPerHour slots that draw from a 1 entry are unscaled —
|
||||
// correct for a fresh instance with no prior history.
|
||||
// -----------------------------------------------------------------------
|
||||
uint8_t samplingDenominator = DENOM_MIN;
|
||||
uint8_t filteringDenominator = DENOM_MIN;
|
||||
uint8_t filteringDenomHoldRollsRemaining = 0; // counts down from FILTER_DENOM_HOLD_ROLLS to 0; step-down fires at 0
|
||||
uint8_t denominatorHistory[13] = {};
|
||||
uint16_t hashSeed = 0;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cached hourly results
|
||||
// -----------------------------------------------------------------------
|
||||
PerHopCounts lastPerHopCounts = {};
|
||||
uint16_t lastScaledPerHop[MAX_HOP + 1] = {};
|
||||
uint8_t lastSuggestedHop = MAX_HOP;
|
||||
uint8_t lastPoliteNumer = POLITENESS_DEFAULT;
|
||||
MeshTrendStats lastTrendStats = {};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hop recommendation state
|
||||
// -----------------------------------------------------------------------
|
||||
uint8_t lastRequiredHop = HOP_MAX;
|
||||
uint8_t histogramRollCount = 0;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scheduler state
|
||||
// -----------------------------------------------------------------------
|
||||
bool hasCompletedInitialRun = false;
|
||||
uint8_t runsSinceLastHourlyUpdate = 0;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Inline record helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Record field semantics:
|
||||
// nodeHash → XOR-fold of full 32-bit node ID to 16 bits
|
||||
// hops_away → hop distance (0–7)
|
||||
// seenHoursAgo → 13-bit per-hour seen bitmap
|
||||
// bit 0 = seen in the current / most-recent hour
|
||||
// bit 12 = seen 12 hours ago
|
||||
// Shifts left on each rollHour(); 0 means not seen in 13 h.
|
||||
|
||||
/// XOR-fold + golden-ratio hash of a 32-bit node ID to 16 bits, mixed with the session seed.
|
||||
/// Multiplying by floor(2^32 / φ) gives uniform avalanche; XORing the seed ensures different
|
||||
/// devices (or the same device after a clear()) sample a different subset of node IDs.
|
||||
/// For seed=0 the function is deterministic, which is used in PIO_UNIT_TESTING builds.
|
||||
uint16_t hashNodeId(uint32_t nodeId) const { return static_cast<uint16_t>((nodeId * 2654435761u) >> 16) ^ hashSeed; }
|
||||
static bool seenInLast13h(const Record &r) { return r.seenHoursAgo != 0u; }
|
||||
static void markCurrentHour(Record &r) { r.seenHoursAgo |= 1u; }
|
||||
static void rollSeenBits(Record &r) { r.seenHoursAgo = (r.seenHoursAgo << 1u) & 0x1FFFu; }
|
||||
static bool passesFilter(uint16_t nodeHash, uint8_t denom) { return (nodeHash & static_cast<uint16_t>(denom - 1u)) == 0u; }
|
||||
|
||||
public:
|
||||
// Clock — public so tests can share the same timebase via HopScalingModule::s_testNowMs
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
static uint32_t nowMs() { return s_testNowMs; }
|
||||
#else
|
||||
static uint32_t nowMs() { return millis(); }
|
||||
#endif
|
||||
};
|
||||
|
||||
extern HopScalingModule *hopScalingModule;
|
||||
|
||||
#endif
|
||||
@@ -41,6 +41,9 @@
|
||||
#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
#endif
|
||||
#include "modules/TextMessageModule.h"
|
||||
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
|
||||
#include "modules/TraceRouteModule.h"
|
||||
@@ -131,6 +134,10 @@ void setupModules()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
hopScalingModule = new HopScalingModule();
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_ADMIN
|
||||
adminModule = new AdminModule();
|
||||
#endif
|
||||
|
||||
@@ -158,8 +158,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply()
|
||||
ignoreRequest = true;
|
||||
return NULL;
|
||||
} else {
|
||||
ignoreRequest = false; // Don't ignore requests anymore
|
||||
meshtastic_User &u = owner;
|
||||
ignoreRequest = false; // Don't ignore requests anymore
|
||||
meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state
|
||||
|
||||
// Strip the public key if the user is licensed
|
||||
if (u.is_licensed && u.public_key.size > 0) {
|
||||
|
||||
@@ -339,6 +339,9 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli()
|
||||
strncpy(takPacket.device_callsign, owner.long_name, sizeof(takPacket.device_callsign) - 1);
|
||||
takPacket.device_callsign[sizeof(takPacket.device_callsign) - 1] = '\0';
|
||||
|
||||
// CoT uid — ATAK drops PLI entities with empty uid; derive stable "!<nodenum>" id.
|
||||
snprintf(takPacket.uid, sizeof(takPacket.uid), "!%08x", nodeDB->getNodeNum());
|
||||
|
||||
// Encode TAKPacketV2 protobuf, leaving room for flags byte prefix
|
||||
uint8_t protobuf_bytes[sizeof(mp->decoded.payload.bytes) - 1];
|
||||
size_t proto_size = pb_encode_to_bytes(protobuf_bytes, sizeof(protobuf_bytes), &meshtastic_TAKPacketV2_msg, &takPacket);
|
||||
|
||||
@@ -94,7 +94,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con
|
||||
const char *warning =
|
||||
"Invalid Serial config: override console serial port is only supported in NMEA and CalTopo output-only modes.";
|
||||
LOG_ERROR(warning);
|
||||
#if !IS_RUNNING_TESTS
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->time = getValidTime(RTCQualityFromNet);
|
||||
|
||||
@@ -71,9 +71,6 @@ int32_t ICM42607PSensor::runOnce()
|
||||
}
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
#else
|
||||
int16_t x = 0;
|
||||
int16_t y = 0;
|
||||
int16_t z = 0;
|
||||
inv_imu_sensor_event_t event = {};
|
||||
|
||||
if (sensor == nullptr || sensor->getDataFromRegisters(event) != 0) {
|
||||
@@ -85,11 +82,8 @@ int32_t ICM42607PSensor::runOnce()
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
x = event.accel[0];
|
||||
y = event.accel[1];
|
||||
z = event.accel[2];
|
||||
// LOG_DEBUG("ICM-42607-P accel read x=%.3fg y=%.3fg z=%.3fg", (float)x / ICM42607P_COUNTS_PER_G,
|
||||
// (float)y / ICM42607P_COUNTS_PER_G, (float)z / ICM42607P_COUNTS_PER_G);
|
||||
// LOG_DEBUG("ICM-42607-P accel read x=%.3fg y=%.3fg z=%.3fg", (float)event.accel[0] / ICM42607P_COUNTS_PER_G,
|
||||
// (float)event.accel[1] / ICM42607P_COUNTS_PER_G, (float)event.accel[2] / ICM42607P_COUNTS_PER_G);
|
||||
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
#endif
|
||||
|
||||
+4
-170
@@ -23,10 +23,6 @@
|
||||
#include <ETH.h>
|
||||
#endif // HAS_ETHERNET
|
||||
#include "Default.h"
|
||||
#if !defined(ARCH_NRF52) || NRF52_USE_JSON
|
||||
#include "serialization/JSON.h"
|
||||
#include "serialization/MeshPacketSerializer.h"
|
||||
#endif
|
||||
#include <Throttle.h>
|
||||
#include <assert.h>
|
||||
#include <utility>
|
||||
@@ -147,96 +143,6 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
||||
router->enqueueReceivedMessage(p.release());
|
||||
}
|
||||
|
||||
#if !defined(ARCH_NRF52) || NRF52_USE_JSON
|
||||
// returns true if this is a valid JSON envelope which we accept on downlink
|
||||
inline bool isValidJsonEnvelope(JSONObject &json)
|
||||
{
|
||||
// Generate node ID from nodenum for comparison
|
||||
std::string nodeId = nodeDB->getNodeId();
|
||||
// if "sender" is provided, avoid processing packets we uplinked
|
||||
return (json.find("sender") != json.end() ? (json["sender"]->AsString().compare(nodeId) != 0) : true) &&
|
||||
(json.find("hopLimit") != json.end() ? json["hopLimit"]->IsNumber() : true) && // hop limit should be a number
|
||||
(json.find("from") != json.end()) && json["from"]->IsNumber() &&
|
||||
(json["from"]->AsNumber() == nodeDB->getNodeNum()) && // only accept message if the "from" is us
|
||||
(json.find("type") != json.end()) && json["type"]->IsString() && // should specify a type
|
||||
(json.find("payload") != json.end()); // should have a payload
|
||||
}
|
||||
|
||||
inline void onReceiveJson(byte *payload, size_t length)
|
||||
{
|
||||
char payloadStr[length + 1];
|
||||
memcpy(payloadStr, payload, length);
|
||||
payloadStr[length] = 0; // null terminated string
|
||||
std::unique_ptr<JSONValue> json_value(JSON::Parse(payloadStr));
|
||||
if (json_value == nullptr) {
|
||||
LOG_ERROR("JSON received payload on MQTT but not a valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject json;
|
||||
json = json_value->AsObject();
|
||||
|
||||
if (!isValidJsonEnvelope(json)) {
|
||||
LOG_ERROR("JSON received payload on MQTT but not a valid envelope");
|
||||
return;
|
||||
}
|
||||
|
||||
// this is a valid envelope
|
||||
if (json["type"]->AsString().compare("sendtext") == 0 && json["payload"]->IsString()) {
|
||||
std::string jsonPayloadStr = json["payload"]->AsString();
|
||||
LOG_INFO("JSON payload %s, length %u", jsonPayloadStr.c_str(), jsonPayloadStr.length());
|
||||
|
||||
// construct protobuf data packet using TEXT_MESSAGE, send it to the mesh
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
||||
if (json.find("channel") != json.end() && json["channel"]->IsNumber() &&
|
||||
(json["channel"]->AsNumber() < channels.getNumChannels()))
|
||||
p->channel = json["channel"]->AsNumber();
|
||||
if (json.find("to") != json.end() && json["to"]->IsNumber())
|
||||
p->to = json["to"]->AsNumber();
|
||||
if (json.find("hopLimit") != json.end() && json["hopLimit"]->IsNumber())
|
||||
p->hop_limit = json["hopLimit"]->AsNumber();
|
||||
if (jsonPayloadStr.length() <= sizeof(p->decoded.payload.bytes)) {
|
||||
memcpy(p->decoded.payload.bytes, jsonPayloadStr.c_str(), jsonPayloadStr.length());
|
||||
p->decoded.payload.size = jsonPayloadStr.length();
|
||||
service->sendToMesh(p, RX_SRC_LOCAL);
|
||||
} else {
|
||||
LOG_WARN("Received MQTT json payload too long, drop");
|
||||
}
|
||||
} else if (json["type"]->AsString().compare("sendposition") == 0 && json["payload"]->IsObject()) {
|
||||
// invent the "sendposition" type for a valid envelope
|
||||
JSONObject posit;
|
||||
posit = json["payload"]->AsObject(); // get nested JSON Position
|
||||
meshtastic_Position pos = meshtastic_Position_init_default;
|
||||
if (posit.find("latitude_i") != posit.end() && posit["latitude_i"]->IsNumber())
|
||||
pos.latitude_i = posit["latitude_i"]->AsNumber();
|
||||
if (posit.find("longitude_i") != posit.end() && posit["longitude_i"]->IsNumber())
|
||||
pos.longitude_i = posit["longitude_i"]->AsNumber();
|
||||
if (posit.find("altitude") != posit.end() && posit["altitude"]->IsNumber())
|
||||
pos.altitude = posit["altitude"]->AsNumber();
|
||||
if (posit.find("time") != posit.end() && posit["time"]->IsNumber())
|
||||
pos.time = posit["time"]->AsNumber();
|
||||
|
||||
// construct protobuf data packet using POSITION, send it to the mesh
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
p->decoded.portnum = meshtastic_PortNum_POSITION_APP;
|
||||
if (json.find("channel") != json.end() && json["channel"]->IsNumber() &&
|
||||
(json["channel"]->AsNumber() < channels.getNumChannels()))
|
||||
p->channel = json["channel"]->AsNumber();
|
||||
if (json.find("to") != json.end() && json["to"]->IsNumber())
|
||||
p->to = json["to"]->AsNumber();
|
||||
if (json.find("hopLimit") != json.end() && json["hopLimit"]->IsNumber())
|
||||
p->hop_limit = json["hopLimit"]->AsNumber();
|
||||
p->decoded.payload.size =
|
||||
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Position_msg,
|
||||
&pos); // make the Data protobuf from position
|
||||
service->sendToMesh(p, RX_SRC_LOCAL);
|
||||
} else {
|
||||
LOG_DEBUG("JSON ignore downlink message with unsupported type");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Determines if the given IPAddress is a private IPv4 address, i.e. not routable on the public internet.
|
||||
bool isPrivateIpAddress(const IPAddress &ip)
|
||||
{
|
||||
@@ -386,26 +292,6 @@ void MQTT::onReceive(char *topic, byte *payload, size_t length)
|
||||
return;
|
||||
}
|
||||
|
||||
// check if this is a json payload message by comparing the topic start
|
||||
if (moduleConfig.mqtt.json_enabled && (strncmp(topic, jsonTopic.c_str(), jsonTopic.length()) == 0)) {
|
||||
#if !defined(ARCH_NRF52) || NRF52_USE_JSON
|
||||
// parse the channel name from the topic string
|
||||
// the topic has been checked above for having jsonTopic prefix, so just move past it
|
||||
char *channelName = topic + jsonTopic.length();
|
||||
// if another "/" was added, parse string up to that character
|
||||
channelName = strtok(channelName, "/") ? strtok(channelName, "/") : channelName;
|
||||
// We allow downlink JSON packets only on a channel named "mqtt"
|
||||
const meshtastic_Channel &sendChannel = channels.getByName(channelName);
|
||||
if (!(strncasecmp(channels.getGlobalId(sendChannel.index), Channels::mqttChannel, strlen(Channels::mqttChannel)) == 0 &&
|
||||
sendChannel.settings.downlink_enabled)) {
|
||||
LOG_WARN("JSON downlink received on channel not called 'mqtt' or without downlink enabled");
|
||||
return;
|
||||
}
|
||||
onReceiveJson(payload, length);
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
onReceiveProto(topic, payload, length);
|
||||
}
|
||||
|
||||
@@ -433,12 +319,10 @@ MQTT::MQTT() : concurrency::OSThread("mqtt"), mqttQueue(MAX_MQTT_QUEUE)
|
||||
|
||||
if (*moduleConfig.mqtt.root) {
|
||||
cryptTopic = moduleConfig.mqtt.root + cryptTopic;
|
||||
jsonTopic = moduleConfig.mqtt.root + jsonTopic;
|
||||
mapTopic = moduleConfig.mqtt.root + mapTopic;
|
||||
isConfiguredForDefaultRootTopic = isDefaultRootTopic(moduleConfig.mqtt.root);
|
||||
} else {
|
||||
cryptTopic = "msh" + cryptTopic;
|
||||
jsonTopic = "msh" + jsonTopic;
|
||||
mapTopic = "msh" + mapTopic;
|
||||
isConfiguredForDefaultRootTopic = true;
|
||||
}
|
||||
@@ -466,7 +350,7 @@ MQTT::MQTT() : concurrency::OSThread("mqtt"), mqttQueue(MAX_MQTT_QUEUE)
|
||||
enabled = true;
|
||||
runASAP = true;
|
||||
reconnectCount = 0;
|
||||
#if !IS_RUNNING_TESTS
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
publishNodeInfo();
|
||||
#endif
|
||||
}
|
||||
@@ -589,14 +473,6 @@ void MQTT::sendSubscriptions()
|
||||
std::string topic = cryptTopic + channels.getGlobalId(i) + "/+";
|
||||
LOG_INFO("Subscribe to %s", topic.c_str());
|
||||
pubSub.subscribe(topic.c_str(), 1); // FIXME, is QOS 1 right?
|
||||
#if !defined(ARCH_NRF52) || \
|
||||
defined(NRF52_USE_JSON) // JSON is not supported on nRF52, see issue #2804 ### Fixed by using ArduinoJSON ###
|
||||
if (moduleConfig.mqtt.json_enabled == true) {
|
||||
std::string topicDecoded = jsonTopic + channels.getGlobalId(i) + "/+";
|
||||
LOG_INFO("Subscribe to %s", topicDecoded.c_str());
|
||||
pubSub.subscribe(topicDecoded.c_str(), 1); // FIXME, is QOS 1 right?
|
||||
}
|
||||
#endif // ARCH_NRF52 NRF52_USE_JSON
|
||||
}
|
||||
}
|
||||
#if !MESHTASTIC_EXCLUDE_PKI
|
||||
@@ -674,7 +550,7 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC
|
||||
const char *warning = "Could not reach the MQTT server. Settings will be saved, but please verify the server "
|
||||
"address and credentials.";
|
||||
LOG_WARN(warning);
|
||||
#if !IS_RUNNING_TESTS
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
if (cn) {
|
||||
cn->level = meshtastic_LogRecord_Level_WARNING;
|
||||
@@ -690,7 +566,7 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC
|
||||
#else
|
||||
const char *warning = "Invalid MQTT config: proxy_to_client_enabled must be enabled on nodes that do not have a network";
|
||||
LOG_ERROR(warning);
|
||||
#if !IS_RUNNING_TESTS
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->time = getValidTime(RTCQualityFromNet);
|
||||
@@ -706,7 +582,7 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC
|
||||
if (defaultServer && !IS_ONE_OF(parsed.serverPort, PubSubConfig::defaultPort, PubSubConfig::defaultPortTls)) {
|
||||
const char *warning = "Invalid MQTT config: default server address must not have a port specified";
|
||||
LOG_ERROR(warning);
|
||||
#if !IS_RUNNING_TESTS
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->time = getValidTime(RTCQualityFromNet);
|
||||
@@ -735,33 +611,6 @@ void MQTT::publishQueuedMessages()
|
||||
const std::unique_ptr<QueueEntry> entry(mqttQueue.dequeuePtr(0));
|
||||
LOG_INFO("publish %s, %u bytes from queue", entry->topic.c_str(), entry->envBytes.size());
|
||||
publish(entry->topic.c_str(), entry->envBytes.data(), entry->envBytes.size(), false);
|
||||
|
||||
#if !defined(ARCH_NRF52) || \
|
||||
defined(NRF52_USE_JSON) // JSON is not supported on nRF52, see issue #2804 ### Fixed by using ArduinoJson ###
|
||||
if (!moduleConfig.mqtt.json_enabled)
|
||||
return;
|
||||
|
||||
// handle json topic
|
||||
const DecodedServiceEnvelope env(entry->envBytes.data(), entry->envBytes.size());
|
||||
if (!env.validDecode || env.packet == NULL || env.channel_id == NULL)
|
||||
return;
|
||||
|
||||
auto jsonString = MeshPacketSerializer::JsonSerialize(env.packet);
|
||||
if (jsonString.length() == 0)
|
||||
return;
|
||||
|
||||
// Generate node ID from nodenum for topic
|
||||
std::string nodeId = nodeDB->getNodeId();
|
||||
|
||||
std::string topicJson;
|
||||
if (env.packet->pki_encrypted) {
|
||||
topicJson = jsonTopic + "PKI/" + nodeId;
|
||||
} else {
|
||||
topicJson = jsonTopic + env.channel_id + "/" + nodeId;
|
||||
}
|
||||
LOG_INFO("JSON publish message to %s, %u bytes: %s", topicJson.c_str(), jsonString.length(), jsonString.c_str());
|
||||
publish(topicJson.c_str(), jsonString.c_str(), false);
|
||||
#endif // ARCH_NRF52 NRF52_USE_JSON
|
||||
}
|
||||
|
||||
void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_MeshPacket &mp_decoded, ChannelIndex chIndex)
|
||||
@@ -825,21 +674,6 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me
|
||||
if (moduleConfig.mqtt.proxy_to_client_enabled || this->isConnectedDirectly()) {
|
||||
LOG_DEBUG("MQTT Publish %s, %u bytes", topic.c_str(), numBytes);
|
||||
publish(topic.c_str(), bytes, numBytes, false);
|
||||
|
||||
#if !defined(ARCH_NRF52) || \
|
||||
defined(NRF52_USE_JSON) // JSON is not supported on nRF52, see issue #2804 ### Fixed by using ArduinoJson ###
|
||||
if (!moduleConfig.mqtt.json_enabled)
|
||||
return;
|
||||
// handle json topic
|
||||
auto jsonString = MeshPacketSerializer::JsonSerialize(&mp_decoded);
|
||||
if (jsonString.length() == 0)
|
||||
return;
|
||||
// Generate node ID from nodenum for JSON topic
|
||||
std::string nodeIdForJson = nodeDB->getNodeId();
|
||||
std::string topicJson = jsonTopic + channelId + "/" + nodeIdForJson;
|
||||
LOG_INFO("JSON publish message to %s, %u bytes: %s", topicJson.c_str(), jsonString.length(), jsonString.c_str());
|
||||
publish(topicJson.c_str(), jsonString.c_str(), false);
|
||||
#endif // ARCH_NRF52 NRF52_USE_JSON
|
||||
} else {
|
||||
LOG_INFO("MQTT not connected, queue packet");
|
||||
QueueEntry *entry;
|
||||
|
||||
+2
-6
@@ -6,9 +6,6 @@
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/generated/meshtastic/mqtt.pb.h"
|
||||
#if !defined(ARCH_NRF52) || NRF52_USE_JSON
|
||||
#include "serialization/JSON.h"
|
||||
#endif
|
||||
#if HAS_WIFI
|
||||
#include <WiFiClient.h>
|
||||
#if __has_include(<WiFiClientSecure.h>)
|
||||
@@ -101,9 +98,8 @@ class MQTT : private concurrency::OSThread
|
||||
explicit MQTT(std::unique_ptr<MQTTClient> mqttClient);
|
||||
#endif
|
||||
|
||||
std::string cryptTopic = "/2/e/"; // msh/2/e/CHANNELID/NODEID
|
||||
std::string jsonTopic = "/2/json/"; // msh/2/json/CHANNELID/NODEID
|
||||
std::string mapTopic = "/2/map/"; // For protobuf-encoded MapReport messages
|
||||
std::string cryptTopic = "/2/e/"; // msh/2/e/CHANNELID/NODEID
|
||||
std::string mapTopic = "/2/map/"; // For protobuf-encoded MapReport messages
|
||||
|
||||
// For map reporting (only applies when enabled)
|
||||
const uint32_t default_map_position_precision = 14; // defaults to max. offset of ~1459m
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// meshtastic_ServiceEnvelope that automatically releases dynamically allocated memory when it goes out of scope.
|
||||
struct DecodedServiceEnvelope : public meshtastic_ServiceEnvelope {
|
||||
DecodedServiceEnvelope(const uint8_t *payload, size_t length);
|
||||
DecodedServiceEnvelope(DecodedServiceEnvelope &) = delete;
|
||||
// const-qualified so std::variant instantiation works on Apple libc++ (copying stays ill-formed either way)
|
||||
DecodedServiceEnvelope(const DecodedServiceEnvelope &) = delete;
|
||||
DecodedServiceEnvelope(DecodedServiceEnvelope &&);
|
||||
~DecodedServiceEnvelope();
|
||||
// Clients must check that this is true before using.
|
||||
|
||||
@@ -40,6 +40,7 @@ constexpr uint16_t kPreferredBleTxTimeUs = (kPreferredBleTxOctets + 14) * 8;
|
||||
|
||||
BLECharacteristic *fromNumCharacteristic;
|
||||
BLECharacteristic *BatteryCharacteristic;
|
||||
static int lastBatteryLevel = -1; // last value written to 0x2A19, to skip redundant writes/notifies
|
||||
BLECharacteristic *logRadioCharacteristic;
|
||||
BLEServer *bleServer;
|
||||
|
||||
@@ -718,6 +719,8 @@ void NimbleBluetooth::deinit()
|
||||
#endif
|
||||
|
||||
BLEDevice::deinit(true);
|
||||
BatteryCharacteristic = nullptr; // freed by deinit; clear so updateBatteryLevel() won't touch it
|
||||
lastBatteryLevel = -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -856,16 +859,31 @@ void NimbleBluetooth::setupService()
|
||||
BatteryCharacteristic = batteryService->createCharacteristic( // 0x2A19 is the Battery Level characteristic)
|
||||
(uint16_t)0x2a19, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
|
||||
BatteryCharacteristic->addDescriptor(batteryLevelDescriptor);
|
||||
// Seed an initial 0-100 level so an early read of 0x2A19 returns a valid value.
|
||||
uint8_t initialLevel = (powerStatus && powerStatus->getHasBattery()) ? powerStatus->getBatteryChargePercent() : 0;
|
||||
if (initialLevel > 100)
|
||||
initialLevel = 100;
|
||||
BatteryCharacteristic->setValue(&initialLevel, 1);
|
||||
lastBatteryLevel = initialLevel;
|
||||
batteryService->start();
|
||||
}
|
||||
|
||||
/// Given a level between 0-100, update the BLE attribute
|
||||
void updateBatteryLevel(uint8_t level)
|
||||
{
|
||||
if ((config.bluetooth.enabled == true) && nimbleBluetooth->isConnected()) {
|
||||
BatteryCharacteristic->setValue(&level, 1);
|
||||
if (!config.bluetooth.enabled || !BatteryCharacteristic)
|
||||
return;
|
||||
|
||||
if (level > 100) // 0x2A19 must stay within the BAS 0-100 range
|
||||
level = 100;
|
||||
if (level == lastBatteryLevel)
|
||||
return;
|
||||
lastBatteryLevel = level;
|
||||
|
||||
// Cache the value so a READ works without a subscriber; notify only when connected.
|
||||
BatteryCharacteristic->setValue(&level, 1);
|
||||
if (nimbleBluetooth && nimbleBluetooth->isConnected())
|
||||
BatteryCharacteristic->notify();
|
||||
}
|
||||
}
|
||||
|
||||
void NimbleBluetooth::clearBonds()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#if __has_include_next("esp_eth_driver.h")
|
||||
#include_next "esp_eth_driver.h"
|
||||
#elif __has_include("esp_eth.h")
|
||||
#include "esp_eth.h"
|
||||
#endif
|
||||
@@ -15,8 +15,9 @@ static BLECharacteristic fromRadio = BLECharacteristic(BLEUuid(FROMRADIO_UUID_16
|
||||
static BLECharacteristic toRadio = BLECharacteristic(BLEUuid(TORADIO_UUID_16));
|
||||
static BLECharacteristic logRadio = BLECharacteristic(BLEUuid(LOGRADIO_UUID_16));
|
||||
|
||||
static BLEDis bledis; // DIS (Device Information Service) helper class instance
|
||||
static BLEBas blebas; // BAS (Battery Service) helper class instance
|
||||
static BLEDis bledis; // DIS (Device Information Service) helper class instance
|
||||
static BLEBas blebas; // BAS (Battery Service) helper class instance
|
||||
static int lastBatteryLevel = -1; // last value written to BAS, to skip redundant writes/notifies
|
||||
#ifndef BLE_DFU_SECURE
|
||||
static BLEDfu bledfu; // DFU software update helper service
|
||||
#else
|
||||
@@ -336,6 +337,7 @@ void NRF52Bluetooth::setup()
|
||||
LOG_INFO("Init the Battery Service");
|
||||
blebas.begin();
|
||||
blebas.write(0); // Unknown battery level for now
|
||||
lastBatteryLevel = 0;
|
||||
// Setup the Heart Rate Monitor service using
|
||||
// BLEService and BLECharacteristic classes
|
||||
LOG_INFO("Init the Mesh bluetooth service");
|
||||
@@ -355,6 +357,14 @@ void NRF52Bluetooth::resumeAdvertising()
|
||||
/// Given a level between 0-100, update the BLE attribute
|
||||
void updateBatteryLevel(uint8_t level)
|
||||
{
|
||||
if (!nrf52Bluetooth) // skip until the Battery Service has been begun in setup()
|
||||
return;
|
||||
|
||||
if (level > 100) // BAS battery level must stay within 0-100
|
||||
level = 100;
|
||||
if (level == lastBatteryLevel)
|
||||
return;
|
||||
lastBatteryLevel = level;
|
||||
blebas.write(level);
|
||||
}
|
||||
void NRF52Bluetooth::clearBonds()
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
#define HW_VENDOR meshtastic_HardwareModel_T_ECHO
|
||||
#elif defined(T_ECHO_LITE)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_T_ECHO_LITE
|
||||
#elif defined(T_ECHO_CARD)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_T_ECHO_CARD
|
||||
#elif defined(TTGO_T_ECHO_PLUS)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_T_ECHO_PLUS
|
||||
#elif defined(ELECROW_ThinkNode_M1)
|
||||
|
||||
@@ -562,7 +562,7 @@ void portduinoSetup()
|
||||
}
|
||||
|
||||
getMacAddr(dmac);
|
||||
#ifndef UNIT_TEST
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
if (dmac[0] == 0 && dmac[1] == 0 && dmac[2] == 0 && dmac[3] == 0 && dmac[4] == 0 && dmac[5] == 0) {
|
||||
std::cout << "*** Blank MAC Address not allowed!" << std::endl;
|
||||
std::cout << "Please set a MAC Address in config.yaml using either MACAddress or MACAddressSource." << std::endl;
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
/*
|
||||
* File JSON.cpp part of the SimpleJSON Library - http://mjpa.in/json
|
||||
*
|
||||
* Copyright (C) 2010 Mike Anchor
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include "JSON.h"
|
||||
|
||||
/**
|
||||
* Blocks off the public constructor
|
||||
*
|
||||
* @access private
|
||||
*
|
||||
*/
|
||||
JSON::JSON() {}
|
||||
|
||||
/**
|
||||
* Parses a complete JSON encoded string
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param char* data The JSON text
|
||||
*
|
||||
* @return JSONValue* Returns a JSON Value representing the root, or NULL on error
|
||||
*/
|
||||
JSONValue *JSON::Parse(const char *data)
|
||||
{
|
||||
// Skip any preceding whitespace, end of data = no JSON = fail
|
||||
if (!SkipWhitespace(&data))
|
||||
return NULL;
|
||||
|
||||
// We need the start of a value here now...
|
||||
JSONValue *value = JSONValue::Parse(&data);
|
||||
if (value == NULL)
|
||||
return NULL;
|
||||
|
||||
// Can be white space now and should be at the end of the string then...
|
||||
if (SkipWhitespace(&data)) {
|
||||
delete value;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// We're now at the end of the string
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the passed in JSONValue into a JSON encode string
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param JSONValue* value The root value
|
||||
*
|
||||
* @return std::string Returns a JSON encoded string representation of the given value
|
||||
*/
|
||||
std::string JSON::Stringify(const JSONValue *value)
|
||||
{
|
||||
if (value != NULL)
|
||||
return value->Stringify();
|
||||
else
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips over any whitespace characters (space, tab, \r or \n) defined by the JSON spec
|
||||
*
|
||||
* @access protected
|
||||
*
|
||||
* @param char** data Pointer to a char* that contains the JSON text
|
||||
*
|
||||
* @return bool Returns true if there is more data, or false if the end of the text was reached
|
||||
*/
|
||||
bool JSON::SkipWhitespace(const char **data)
|
||||
{
|
||||
while (**data != 0 && (**data == ' ' || **data == '\t' || **data == '\r' || **data == '\n'))
|
||||
(*data)++;
|
||||
|
||||
return **data != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a JSON String as defined by the spec - "<some chars>"
|
||||
* Any escaped characters are swapped out for their unescaped values
|
||||
*
|
||||
* @access protected
|
||||
*
|
||||
* @param char** data Pointer to a char* that contains the JSON text
|
||||
* @param std::string& str Reference to a std::string to receive the extracted string
|
||||
*
|
||||
* @return bool Returns true on success, false on failure
|
||||
*/
|
||||
bool JSON::ExtractString(const char **data, std::string &str)
|
||||
{
|
||||
str = "";
|
||||
|
||||
while (**data != 0) {
|
||||
// Save the char so we can change it if need be
|
||||
char next_char = **data;
|
||||
|
||||
// Escaping something?
|
||||
if (next_char == '\\') {
|
||||
// Move over the escape char
|
||||
(*data)++;
|
||||
|
||||
// Deal with the escaped char
|
||||
switch (**data) {
|
||||
case '"':
|
||||
next_char = '"';
|
||||
break;
|
||||
case '\\':
|
||||
next_char = '\\';
|
||||
break;
|
||||
case '/':
|
||||
next_char = '/';
|
||||
break;
|
||||
case 'b':
|
||||
next_char = '\b';
|
||||
break;
|
||||
case 'f':
|
||||
next_char = '\f';
|
||||
break;
|
||||
case 'n':
|
||||
next_char = '\n';
|
||||
break;
|
||||
case 'r':
|
||||
next_char = '\r';
|
||||
break;
|
||||
case 't':
|
||||
next_char = '\t';
|
||||
break;
|
||||
case 'u': {
|
||||
// We need 5 chars (4 hex + the 'u') or its not valid
|
||||
if (!simplejson_csnlen(*data, 5))
|
||||
return false;
|
||||
|
||||
// Deal with the chars
|
||||
next_char = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
// Do it first to move off the 'u' and leave us on the
|
||||
// final hex digit as we move on by one later on
|
||||
(*data)++;
|
||||
|
||||
next_char <<= 4;
|
||||
|
||||
// Parse the hex digit
|
||||
if (**data >= '0' && **data <= '9')
|
||||
next_char |= (**data - '0');
|
||||
else if (**data >= 'A' && **data <= 'F')
|
||||
next_char |= (10 + (**data - 'A'));
|
||||
else if (**data >= 'a' && **data <= 'f')
|
||||
next_char |= (10 + (**data - 'a'));
|
||||
else {
|
||||
// Invalid hex digit = invalid JSON
|
||||
return false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// By the spec, only the above cases are allowed
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// End of the string?
|
||||
else if (next_char == '"') {
|
||||
(*data)++;
|
||||
str.shrink_to_fit(); // Remove unused capacity
|
||||
return true;
|
||||
}
|
||||
|
||||
// Disallowed char?
|
||||
else if (next_char < ' ' && next_char != '\t') {
|
||||
// SPEC Violation: Allow tabs due to real world cases
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add the next char
|
||||
str += next_char;
|
||||
|
||||
// Move on
|
||||
(*data)++;
|
||||
}
|
||||
|
||||
// If we're here, the string ended incorrectly
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses some text as though it is an integer
|
||||
*
|
||||
* @access protected
|
||||
*
|
||||
* @param char** data Pointer to a char* that contains the JSON text
|
||||
*
|
||||
* @return double Returns the double value of the number found
|
||||
*/
|
||||
double JSON::ParseInt(const char **data)
|
||||
{
|
||||
double integer = 0;
|
||||
while (**data != 0 && **data >= '0' && **data <= '9')
|
||||
integer = integer * 10 + (*(*data)++ - '0');
|
||||
|
||||
return integer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses some text as though it is a decimal
|
||||
*
|
||||
* @access protected
|
||||
*
|
||||
* @param char** data Pointer to a char* that contains the JSON text
|
||||
*
|
||||
* @return double Returns the double value of the decimal found
|
||||
*/
|
||||
double JSON::ParseDecimal(const char **data)
|
||||
{
|
||||
double decimal = 0.0;
|
||||
double factor = 0.1;
|
||||
while (**data != 0 && **data >= '0' && **data <= '9') {
|
||||
int digit = (*(*data)++ - '0');
|
||||
decimal = decimal + digit * factor;
|
||||
factor *= 0.1;
|
||||
}
|
||||
return decimal;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* File JSON.h part of the SimpleJSON Library - http://mjpa.in/json
|
||||
*
|
||||
* Copyright (C) 2010 Mike Anchor
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef _JSON_H_
|
||||
#define _JSON_H_
|
||||
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Simple function to check a string 's' has at least 'n' characters
|
||||
static inline bool simplejson_csnlen(const char *s, size_t n)
|
||||
{
|
||||
if (s == 0)
|
||||
return false;
|
||||
|
||||
const char *save = s;
|
||||
while (n-- > 0) {
|
||||
if (*(save++) == 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Custom types
|
||||
class JSONValue;
|
||||
typedef std::vector<JSONValue *> JSONArray;
|
||||
typedef std::map<std::string, JSONValue *> JSONObject;
|
||||
|
||||
#include "JSONValue.h"
|
||||
|
||||
class JSON
|
||||
{
|
||||
friend class JSONValue;
|
||||
|
||||
public:
|
||||
static JSONValue *Parse(const char *data);
|
||||
static std::string Stringify(const JSONValue *value);
|
||||
|
||||
protected:
|
||||
static bool SkipWhitespace(const char **data);
|
||||
static bool ExtractString(const char **data, std::string &str);
|
||||
static double ParseInt(const char **data);
|
||||
static double ParseDecimal(const char **data);
|
||||
|
||||
private:
|
||||
JSON();
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,897 +0,0 @@
|
||||
/*
|
||||
* File JSONValue.cpp part of the SimpleJSON Library - http://mjpa.in/json
|
||||
*
|
||||
* Copyright (C) 2010 Mike Anchor
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
#include <sstream>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "JSONValue.h"
|
||||
|
||||
// Macros to free an array/object
|
||||
#define FREE_ARRAY(x) \
|
||||
{ \
|
||||
JSONArray::iterator iter; \
|
||||
for (iter = x.begin(); iter != x.end(); ++iter) { \
|
||||
delete *iter; \
|
||||
} \
|
||||
}
|
||||
#define FREE_OBJECT(x) \
|
||||
{ \
|
||||
JSONObject::iterator iter; \
|
||||
for (iter = x.begin(); iter != x.end(); ++iter) { \
|
||||
delete (*iter).second; \
|
||||
} \
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON encoded value to a JSONValue object
|
||||
*
|
||||
* @access protected
|
||||
*
|
||||
* @param char** data Pointer to a char* that contains the data
|
||||
*
|
||||
* @return JSONValue* Returns a pointer to a JSONValue object on success, NULL on error
|
||||
*/
|
||||
JSONValue *JSONValue::Parse(const char **data)
|
||||
{
|
||||
// Is it a string?
|
||||
if (**data == '"') {
|
||||
std::string str;
|
||||
if (!JSON::ExtractString(&(++(*data)), str))
|
||||
return NULL;
|
||||
else
|
||||
return new JSONValue(str);
|
||||
}
|
||||
|
||||
// Is it a boolean?
|
||||
else if ((simplejson_csnlen(*data, 4) && strncasecmp(*data, "true", 4) == 0) ||
|
||||
(simplejson_csnlen(*data, 5) && strncasecmp(*data, "false", 5) == 0)) {
|
||||
bool value = strncasecmp(*data, "true", 4) == 0;
|
||||
(*data) += value ? 4 : 5;
|
||||
return new JSONValue(value);
|
||||
}
|
||||
|
||||
// Is it a null?
|
||||
else if (simplejson_csnlen(*data, 4) && strncasecmp(*data, "null", 4) == 0) {
|
||||
(*data) += 4;
|
||||
return new JSONValue();
|
||||
}
|
||||
|
||||
// Is it a number?
|
||||
else if (**data == '-' || (**data >= '0' && **data <= '9')) {
|
||||
// Negative?
|
||||
bool neg = **data == '-';
|
||||
if (neg)
|
||||
(*data)++;
|
||||
|
||||
double number = 0.0;
|
||||
|
||||
// Parse the whole part of the number - only if it wasn't 0
|
||||
if (**data == '0')
|
||||
(*data)++;
|
||||
else if (**data >= '1' && **data <= '9')
|
||||
number = JSON::ParseInt(data);
|
||||
else
|
||||
return NULL;
|
||||
|
||||
// Could be a decimal now...
|
||||
if (**data == '.') {
|
||||
(*data)++;
|
||||
|
||||
// Not get any digits?
|
||||
if (!(**data >= '0' && **data <= '9'))
|
||||
return NULL;
|
||||
|
||||
// Find the decimal and sort the decimal place out
|
||||
// Use ParseDecimal as ParseInt won't work with decimals less than 0.1
|
||||
// thanks to Javier Abadia for the report & fix
|
||||
double decimal = JSON::ParseDecimal(data);
|
||||
|
||||
// Save the number
|
||||
number += decimal;
|
||||
}
|
||||
|
||||
// Could be an exponent now...
|
||||
if (**data == 'E' || **data == 'e') {
|
||||
(*data)++;
|
||||
|
||||
// Check signage of expo
|
||||
bool neg_expo = false;
|
||||
if (**data == '-' || **data == '+') {
|
||||
neg_expo = **data == '-';
|
||||
(*data)++;
|
||||
}
|
||||
|
||||
// Not get any digits?
|
||||
if (!(**data >= '0' && **data <= '9'))
|
||||
return NULL;
|
||||
|
||||
// Sort the expo out
|
||||
double expo = JSON::ParseInt(data);
|
||||
for (double i = 0.0; i < expo; i++)
|
||||
number = neg_expo ? (number / 10.0) : (number * 10.0);
|
||||
}
|
||||
|
||||
// Was it neg?
|
||||
if (neg)
|
||||
number *= -1;
|
||||
|
||||
return new JSONValue(number);
|
||||
}
|
||||
|
||||
// An object?
|
||||
else if (**data == '{') {
|
||||
JSONObject object;
|
||||
|
||||
(*data)++;
|
||||
|
||||
while (**data != 0) {
|
||||
// Whitespace at the start?
|
||||
if (!JSON::SkipWhitespace(data)) {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Special case - empty object
|
||||
if (object.size() == 0 && **data == '}') {
|
||||
(*data)++;
|
||||
return new JSONValue(object);
|
||||
}
|
||||
|
||||
// We want a string now...
|
||||
std::string name;
|
||||
if (!JSON::ExtractString(&(++(*data)), name)) {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// More whitespace?
|
||||
if (!JSON::SkipWhitespace(data)) {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Need a : now
|
||||
if (*((*data)++) != ':') {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// More whitespace?
|
||||
if (!JSON::SkipWhitespace(data)) {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// The value is here
|
||||
JSONValue *value = Parse(data);
|
||||
if (value == NULL) {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Add the name:value
|
||||
if (object.find(name) != object.end())
|
||||
delete object[name];
|
||||
object[name] = value;
|
||||
|
||||
// More whitespace?
|
||||
if (!JSON::SkipWhitespace(data)) {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// End of object?
|
||||
if (**data == '}') {
|
||||
(*data)++;
|
||||
return new JSONValue(object);
|
||||
}
|
||||
|
||||
// Want a , now
|
||||
if (**data != ',') {
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
(*data)++;
|
||||
}
|
||||
|
||||
// Only here if we ran out of data
|
||||
FREE_OBJECT(object);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// An array?
|
||||
else if (**data == '[') {
|
||||
JSONArray array;
|
||||
|
||||
(*data)++;
|
||||
|
||||
while (**data != 0) {
|
||||
// Whitespace at the start?
|
||||
if (!JSON::SkipWhitespace(data)) {
|
||||
FREE_ARRAY(array);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Special case - empty array
|
||||
if (array.size() == 0 && **data == ']') {
|
||||
(*data)++;
|
||||
return new JSONValue(array);
|
||||
}
|
||||
|
||||
// Get the value
|
||||
JSONValue *value = Parse(data);
|
||||
if (value == NULL) {
|
||||
FREE_ARRAY(array);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Add the value
|
||||
array.push_back(value);
|
||||
|
||||
// More whitespace?
|
||||
if (!JSON::SkipWhitespace(data)) {
|
||||
FREE_ARRAY(array);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// End of array?
|
||||
if (**data == ']') {
|
||||
(*data)++;
|
||||
return new JSONValue(array);
|
||||
}
|
||||
|
||||
// Want a , now
|
||||
if (**data != ',') {
|
||||
FREE_ARRAY(array);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
(*data)++;
|
||||
}
|
||||
|
||||
// Only here if we ran out of data
|
||||
FREE_ARRAY(array);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Ran out of possibilities, it's bad!
|
||||
else {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type NULL
|
||||
*
|
||||
* @access public
|
||||
*/
|
||||
JSONValue::JSONValue(/*NULL*/)
|
||||
{
|
||||
type = JSONType_Null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type String
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param char* m_char_value The string to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(const char *m_char_value)
|
||||
{
|
||||
type = JSONType_String;
|
||||
string_value = new std::string(std::string(m_char_value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type String
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param std::string m_string_value The string to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(const std::string &m_string_value)
|
||||
{
|
||||
type = JSONType_String;
|
||||
string_value = new std::string(m_string_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type Bool
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param bool m_bool_value The bool to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(bool m_bool_value)
|
||||
{
|
||||
type = JSONType_Bool;
|
||||
bool_value = m_bool_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type Number
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param double m_number_value The number to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(double m_number_value)
|
||||
{
|
||||
type = JSONType_Number;
|
||||
number_value = m_number_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type Number
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param int m_integer_value The number to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(int m_integer_value)
|
||||
{
|
||||
type = JSONType_Number;
|
||||
number_value = (double)m_integer_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type Number
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param unsigned int m_integer_value The number to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(unsigned int m_integer_value)
|
||||
{
|
||||
type = JSONType_Number;
|
||||
number_value = (double)m_integer_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type Array
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param JSONArray m_array_value The JSONArray to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(const JSONArray &m_array_value)
|
||||
{
|
||||
type = JSONType_Array;
|
||||
array_value = new JSONArray(m_array_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic constructor for creating a JSON Value of type Object
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param JSONObject m_object_value The JSONObject to use as the value
|
||||
*/
|
||||
JSONValue::JSONValue(const JSONObject &m_object_value)
|
||||
{
|
||||
type = JSONType_Object;
|
||||
object_value = new JSONObject(m_object_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy constructor to perform a deep copy of array / object values
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param JSONValue m_source The source JSONValue that is being copied
|
||||
*/
|
||||
JSONValue::JSONValue(const JSONValue &m_source)
|
||||
{
|
||||
type = m_source.type;
|
||||
|
||||
switch (type) {
|
||||
case JSONType_String:
|
||||
string_value = new std::string(*m_source.string_value);
|
||||
break;
|
||||
|
||||
case JSONType_Bool:
|
||||
bool_value = m_source.bool_value;
|
||||
break;
|
||||
|
||||
case JSONType_Number:
|
||||
number_value = m_source.number_value;
|
||||
break;
|
||||
|
||||
case JSONType_Array: {
|
||||
JSONArray source_array = *m_source.array_value;
|
||||
JSONArray::iterator iter;
|
||||
array_value = new JSONArray();
|
||||
for (iter = source_array.begin(); iter != source_array.end(); ++iter)
|
||||
array_value->push_back(new JSONValue(**iter));
|
||||
break;
|
||||
}
|
||||
|
||||
case JSONType_Object: {
|
||||
JSONObject source_object = *m_source.object_value;
|
||||
object_value = new JSONObject();
|
||||
JSONObject::iterator iter;
|
||||
for (iter = source_object.begin(); iter != source_object.end(); ++iter) {
|
||||
std::string name = (*iter).first;
|
||||
(*object_value)[name] = new JSONValue(*((*iter).second));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case JSONType_Null:
|
||||
// Nothing to do.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The destructor for the JSON Value object
|
||||
* Handles deleting the objects in the array or the object value
|
||||
*
|
||||
* @access public
|
||||
*/
|
||||
JSONValue::~JSONValue()
|
||||
{
|
||||
if (type == JSONType_Array) {
|
||||
JSONArray::iterator iter;
|
||||
for (iter = array_value->begin(); iter != array_value->end(); ++iter)
|
||||
delete *iter;
|
||||
delete array_value;
|
||||
} else if (type == JSONType_Object) {
|
||||
JSONObject::iterator iter;
|
||||
for (iter = object_value->begin(); iter != object_value->end(); ++iter) {
|
||||
delete (*iter).second;
|
||||
}
|
||||
delete object_value;
|
||||
} else if (type == JSONType_String) {
|
||||
delete string_value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is a NULL
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if it is a NULL value, false otherwise
|
||||
*/
|
||||
bool JSONValue::IsNull() const
|
||||
{
|
||||
return type == JSONType_Null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is a String
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if it is a String value, false otherwise
|
||||
*/
|
||||
bool JSONValue::IsString() const
|
||||
{
|
||||
return type == JSONType_String;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is a Bool
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if it is a Bool value, false otherwise
|
||||
*/
|
||||
bool JSONValue::IsBool() const
|
||||
{
|
||||
return type == JSONType_Bool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is a Number
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if it is a Number value, false otherwise
|
||||
*/
|
||||
bool JSONValue::IsNumber() const
|
||||
{
|
||||
return type == JSONType_Number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is an Array
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if it is an Array value, false otherwise
|
||||
*/
|
||||
bool JSONValue::IsArray() const
|
||||
{
|
||||
return type == JSONType_Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is an Object
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if it is an Object value, false otherwise
|
||||
*/
|
||||
bool JSONValue::IsObject() const
|
||||
{
|
||||
return type == JSONType_Object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the String value of this JSONValue
|
||||
* Use IsString() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return std::string Returns the string value
|
||||
*/
|
||||
const std::string &JSONValue::AsString() const
|
||||
{
|
||||
return (*string_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Bool value of this JSONValue
|
||||
* Use IsBool() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns the bool value
|
||||
*/
|
||||
bool JSONValue::AsBool() const
|
||||
{
|
||||
return bool_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Number value of this JSONValue
|
||||
* Use IsNumber() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return double Returns the number value
|
||||
*/
|
||||
double JSONValue::AsNumber() const
|
||||
{
|
||||
return number_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Array value of this JSONValue
|
||||
* Use IsArray() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return JSONArray Returns the array value
|
||||
*/
|
||||
const JSONArray &JSONValue::AsArray() const
|
||||
{
|
||||
return (*array_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Object value of this JSONValue
|
||||
* Use IsObject() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return JSONObject Returns the object value
|
||||
*/
|
||||
const JSONObject &JSONValue::AsObject() const
|
||||
{
|
||||
return (*object_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the number of children of this JSONValue.
|
||||
* This number will be 0 or the actual number of children
|
||||
* if IsArray() or IsObject().
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return The number of children.
|
||||
*/
|
||||
std::size_t JSONValue::CountChildren() const
|
||||
{
|
||||
switch (type) {
|
||||
case JSONType_Array:
|
||||
return array_value->size();
|
||||
case JSONType_Object:
|
||||
return object_value->size();
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this JSONValue has a child at the given index.
|
||||
* Use IsArray() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if the array has a value at the given index.
|
||||
*/
|
||||
bool JSONValue::HasChild(std::size_t index) const
|
||||
{
|
||||
if (type == JSONType_Array) {
|
||||
return index < array_value->size();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the child of this JSONValue at the given index.
|
||||
* Use IsArray() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return JSONValue* Returns JSONValue at the given index or NULL
|
||||
* if it doesn't exist.
|
||||
*/
|
||||
JSONValue *JSONValue::Child(std::size_t index)
|
||||
{
|
||||
if (index < array_value->size()) {
|
||||
return (*array_value)[index];
|
||||
} else {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this JSONValue has a child at the given key.
|
||||
* Use IsObject() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return bool Returns true if the object has a value at the given key.
|
||||
*/
|
||||
bool JSONValue::HasChild(const char *name) const
|
||||
{
|
||||
if (type == JSONType_Object) {
|
||||
return object_value->find(name) != object_value->end();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the child of this JSONValue at the given key.
|
||||
* Use IsObject() before using this method.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return JSONValue* Returns JSONValue for the given key in the object
|
||||
* or NULL if it doesn't exist.
|
||||
*/
|
||||
JSONValue *JSONValue::Child(const char *name)
|
||||
{
|
||||
JSONObject::const_iterator it = object_value->find(name);
|
||||
if (it != object_value->end()) {
|
||||
return it->second;
|
||||
} else {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the keys of the JSON Object or an empty vector
|
||||
* if this value is not an object.
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @return std::vector<std::string> A vector containing the keys.
|
||||
*/
|
||||
std::vector<std::string> JSONValue::ObjectKeys() const
|
||||
{
|
||||
std::vector<std::string> keys;
|
||||
|
||||
if (type == JSONType_Object) {
|
||||
JSONObject::const_iterator iter = object_value->begin();
|
||||
while (iter != object_value->end()) {
|
||||
keys.push_back(iter->first);
|
||||
|
||||
++iter;
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON encoded string for the value with all necessary characters escaped
|
||||
*
|
||||
* @access public
|
||||
*
|
||||
* @param bool prettyprint Enable prettyprint
|
||||
*
|
||||
* @return std::string Returns the JSON string
|
||||
*/
|
||||
std::string JSONValue::Stringify(bool const prettyprint) const
|
||||
{
|
||||
size_t const indentDepth = prettyprint ? 1 : 0;
|
||||
return StringifyImpl(indentDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON encoded string for the value with all necessary characters escaped
|
||||
*
|
||||
* @access private
|
||||
*
|
||||
* @param size_t indentDepth The prettyprint indentation depth (0 : no prettyprint)
|
||||
*
|
||||
* @return std::string Returns the JSON string
|
||||
*/
|
||||
std::string JSONValue::StringifyImpl(size_t const indentDepth) const
|
||||
{
|
||||
std::string ret_string;
|
||||
size_t const indentDepth1 = indentDepth ? indentDepth + 1 : 0;
|
||||
std::string const indentStr = Indent(indentDepth);
|
||||
std::string const indentStr1 = Indent(indentDepth1);
|
||||
|
||||
switch (type) {
|
||||
case JSONType_Null:
|
||||
ret_string = "null";
|
||||
break;
|
||||
|
||||
case JSONType_String:
|
||||
ret_string = StringifyString(*string_value);
|
||||
break;
|
||||
|
||||
case JSONType_Bool:
|
||||
ret_string = bool_value ? "true" : "false";
|
||||
break;
|
||||
|
||||
case JSONType_Number: {
|
||||
if (isinf(number_value) || isnan(number_value))
|
||||
ret_string = "null";
|
||||
else {
|
||||
std::stringstream ss;
|
||||
ss.precision(15);
|
||||
ss << number_value;
|
||||
ret_string = ss.str();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case JSONType_Array: {
|
||||
ret_string = indentDepth ? "[\n" + indentStr1 : "[";
|
||||
JSONArray::const_iterator iter = array_value->begin();
|
||||
while (iter != array_value->end()) {
|
||||
ret_string += (*iter)->StringifyImpl(indentDepth1);
|
||||
|
||||
// Not at the end - add a separator
|
||||
if (++iter != array_value->end())
|
||||
ret_string += ",";
|
||||
}
|
||||
ret_string += indentDepth ? "\n" + indentStr + "]" : "]";
|
||||
break;
|
||||
}
|
||||
|
||||
case JSONType_Object: {
|
||||
ret_string = indentDepth ? "{\n" + indentStr1 : "{";
|
||||
JSONObject::const_iterator iter = object_value->begin();
|
||||
while (iter != object_value->end()) {
|
||||
ret_string += StringifyString((*iter).first);
|
||||
ret_string += ":";
|
||||
ret_string += (*iter).second->StringifyImpl(indentDepth1);
|
||||
|
||||
// Not at the end - add a separator
|
||||
if (++iter != object_value->end())
|
||||
ret_string += ",";
|
||||
}
|
||||
ret_string += indentDepth ? "\n" + indentStr + "}" : "}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ret_string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON encoded string with all required fields escaped
|
||||
* Works from http://www.ecma-internationl.org/publications/files/ECMA-ST/ECMA-262.pdf
|
||||
* Section 15.12.3.
|
||||
*
|
||||
* @access private
|
||||
*
|
||||
* @param std::string str The string that needs to have the characters escaped
|
||||
*
|
||||
* @return std::string Returns the JSON string
|
||||
*/
|
||||
std::string JSONValue::StringifyString(const std::string &str)
|
||||
{
|
||||
std::string str_out = "\"";
|
||||
|
||||
std::string::const_iterator iter = str.begin();
|
||||
while (iter != str.end()) {
|
||||
char chr = *iter;
|
||||
|
||||
if (chr == '"' || chr == '\\' || chr == '/') {
|
||||
str_out += '\\';
|
||||
str_out += chr;
|
||||
} else if (chr == '\b') {
|
||||
str_out += "\\b";
|
||||
} else if (chr == '\f') {
|
||||
str_out += "\\f";
|
||||
} else if (chr == '\n') {
|
||||
str_out += "\\n";
|
||||
} else if (chr == '\r') {
|
||||
str_out += "\\r";
|
||||
} else if (chr == '\t') {
|
||||
str_out += "\\t";
|
||||
} else if (chr < 0x20 || chr == 0x7F) {
|
||||
char buf[7];
|
||||
snprintf(buf, sizeof(buf), "\\u%04x", chr);
|
||||
str_out += buf;
|
||||
} else if (chr < 0x80) {
|
||||
str_out += chr;
|
||||
} else {
|
||||
str_out += chr;
|
||||
size_t remain = str.end() - iter - 1;
|
||||
if ((chr & 0xE0) == 0xC0 && remain >= 1) {
|
||||
++iter;
|
||||
str_out += *iter;
|
||||
} else if ((chr & 0xF0) == 0xE0 && remain >= 2) {
|
||||
str_out += *(++iter);
|
||||
str_out += *(++iter);
|
||||
} else if ((chr & 0xF8) == 0xF0 && remain >= 3) {
|
||||
str_out += *(++iter);
|
||||
str_out += *(++iter);
|
||||
str_out += *(++iter);
|
||||
}
|
||||
}
|
||||
|
||||
++iter;
|
||||
}
|
||||
|
||||
str_out += "\"";
|
||||
return str_out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the indentation string for the depth given
|
||||
*
|
||||
* @access private
|
||||
*
|
||||
* @param size_t indent The prettyprint indentation depth (0 : no indentation)
|
||||
*
|
||||
* @return std::string Returns the string
|
||||
*/
|
||||
std::string JSONValue::Indent(size_t depth)
|
||||
{
|
||||
const size_t indent_step = 2;
|
||||
depth ? --depth : 0;
|
||||
std::string indentStr(depth * indent_step, ' ');
|
||||
return indentStr;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* File JSONValue.h part of the SimpleJSON Library - http://mjpa.in/json
|
||||
*
|
||||
* Copyright (C) 2010 Mike Anchor
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef _JSONVALUE_H_
|
||||
#define _JSONVALUE_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "JSON.h"
|
||||
|
||||
class JSON;
|
||||
|
||||
enum JSONType { JSONType_Null, JSONType_String, JSONType_Bool, JSONType_Number, JSONType_Array, JSONType_Object };
|
||||
|
||||
class JSONValue
|
||||
{
|
||||
friend class JSON;
|
||||
|
||||
public:
|
||||
JSONValue(/*NULL*/);
|
||||
explicit JSONValue(const char *m_char_value);
|
||||
explicit JSONValue(const std::string &m_string_value);
|
||||
explicit JSONValue(bool m_bool_value);
|
||||
explicit JSONValue(double m_number_value);
|
||||
explicit JSONValue(int m_integer_value);
|
||||
explicit JSONValue(unsigned int m_integer_value);
|
||||
explicit JSONValue(const JSONArray &m_array_value);
|
||||
explicit JSONValue(const JSONObject &m_object_value);
|
||||
explicit JSONValue(const JSONValue &m_source);
|
||||
~JSONValue();
|
||||
|
||||
bool IsNull() const;
|
||||
bool IsString() const;
|
||||
bool IsBool() const;
|
||||
bool IsNumber() const;
|
||||
bool IsArray() const;
|
||||
bool IsObject() const;
|
||||
|
||||
const std::string &AsString() const;
|
||||
bool AsBool() const;
|
||||
double AsNumber() const;
|
||||
const JSONArray &AsArray() const;
|
||||
const JSONObject &AsObject() const;
|
||||
|
||||
std::size_t CountChildren() const;
|
||||
bool HasChild(std::size_t index) const;
|
||||
JSONValue *Child(std::size_t index);
|
||||
bool HasChild(const char *name) const;
|
||||
JSONValue *Child(const char *name);
|
||||
std::vector<std::string> ObjectKeys() const;
|
||||
|
||||
std::string Stringify(bool const prettyprint = false) const;
|
||||
|
||||
protected:
|
||||
static JSONValue *Parse(const char **data);
|
||||
|
||||
private:
|
||||
static std::string StringifyString(const std::string &str);
|
||||
std::string StringifyImpl(size_t const indentDepth) const;
|
||||
static std::string Indent(size_t depth);
|
||||
|
||||
JSONType type;
|
||||
|
||||
union {
|
||||
bool bool_value;
|
||||
double number_value;
|
||||
std::string *string_value;
|
||||
JSONArray *array_value;
|
||||
JSONObject *object_value;
|
||||
};
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,11 +1,12 @@
|
||||
#ifndef NRF52_USE_JSON
|
||||
#if ARCH_PORTDUINO
|
||||
#include "MeshPacketSerializer.h"
|
||||
#include "JSON.h"
|
||||
#include "NodeDB.h"
|
||||
#include "mesh/generated/meshtastic/mqtt.pb.h"
|
||||
#include "mesh/generated/meshtastic/telemetry.pb.h"
|
||||
#include "modules/RoutingModule.h"
|
||||
#include <DebugConfiguration.h>
|
||||
#include <json/json.h>
|
||||
#include <memory>
|
||||
#include <mesh-pb-constants.h>
|
||||
#if defined(ARCH_ESP32)
|
||||
#include "../mesh/generated/meshtastic/paxcount.pb.h"
|
||||
@@ -15,41 +16,49 @@
|
||||
|
||||
static const char *errStr = "Error decoding proto for %s message!";
|
||||
|
||||
static std::string writeCompact(const Json::Value &v)
|
||||
{
|
||||
Json::StreamWriterBuilder b;
|
||||
b["indentation"] = "";
|
||||
b["emitUTF8"] = true;
|
||||
return Json::writeString(b, v);
|
||||
}
|
||||
|
||||
static bool tryParseJson(const char *s, Json::Value &out)
|
||||
{
|
||||
Json::CharReaderBuilder b;
|
||||
std::unique_ptr<Json::CharReader> reader(b.newCharReader());
|
||||
std::string errs;
|
||||
const char *end = s + strlen(s);
|
||||
return reader->parse(s, end, &out, &errs);
|
||||
}
|
||||
|
||||
std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, bool shouldLog)
|
||||
{
|
||||
// the created jsonObj is immutable after creation, so
|
||||
// we need to do the heavy lifting before assembling it.
|
||||
std::string msgType;
|
||||
JSONObject jsonObj;
|
||||
Json::Value jsonObj(Json::objectValue);
|
||||
|
||||
if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
|
||||
JSONObject msgPayload;
|
||||
Json::Value msgPayload(Json::objectValue);
|
||||
switch (mp->decoded.portnum) {
|
||||
case meshtastic_PortNum_TEXT_MESSAGE_APP: {
|
||||
msgType = "text";
|
||||
// convert bytes to string
|
||||
if (shouldLog)
|
||||
LOG_DEBUG("got text message of size %u", mp->decoded.payload.size);
|
||||
|
||||
char payloadStr[(mp->decoded.payload.size) + 1];
|
||||
memcpy(payloadStr, mp->decoded.payload.bytes, mp->decoded.payload.size);
|
||||
payloadStr[mp->decoded.payload.size] = 0; // null terminated string
|
||||
// check if this is a JSON payload
|
||||
JSONValue *json_value = JSON::Parse(payloadStr);
|
||||
if (json_value != NULL) {
|
||||
payloadStr[mp->decoded.payload.size] = 0;
|
||||
Json::Value parsed;
|
||||
if (tryParseJson(payloadStr, parsed)) {
|
||||
if (shouldLog)
|
||||
LOG_INFO("text message payload is of type json");
|
||||
|
||||
// if it is, then we can just use the json object
|
||||
jsonObj["payload"] = json_value;
|
||||
jsonObj["payload"] = parsed;
|
||||
} else {
|
||||
// if it isn't, then we need to create a json object
|
||||
// with the string as the value
|
||||
if (shouldLog)
|
||||
LOG_INFO("text message payload is of type plaintext");
|
||||
|
||||
msgPayload["text"] = new JSONValue(payloadStr);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["text"] = payloadStr;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -61,133 +70,129 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_Telemetry_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
if (decoded->which_variant == meshtastic_Telemetry_device_metrics_tag) {
|
||||
// If battery is present, encode the battery level value
|
||||
// TODO - Add a condition to send a code for a non-present value
|
||||
if (decoded->variant.device_metrics.has_battery_level) {
|
||||
msgPayload["battery_level"] = new JSONValue((int)decoded->variant.device_metrics.battery_level);
|
||||
msgPayload["battery_level"] = (int)decoded->variant.device_metrics.battery_level;
|
||||
}
|
||||
msgPayload["voltage"] = new JSONValue(decoded->variant.device_metrics.voltage);
|
||||
msgPayload["channel_utilization"] = new JSONValue(decoded->variant.device_metrics.channel_utilization);
|
||||
msgPayload["air_util_tx"] = new JSONValue(decoded->variant.device_metrics.air_util_tx);
|
||||
msgPayload["uptime_seconds"] = new JSONValue((unsigned int)decoded->variant.device_metrics.uptime_seconds);
|
||||
msgPayload["voltage"] = decoded->variant.device_metrics.voltage;
|
||||
msgPayload["channel_utilization"] = decoded->variant.device_metrics.channel_utilization;
|
||||
msgPayload["air_util_tx"] = decoded->variant.device_metrics.air_util_tx;
|
||||
msgPayload["uptime_seconds"] = (Json::UInt)decoded->variant.device_metrics.uptime_seconds;
|
||||
} else if (decoded->which_variant == meshtastic_Telemetry_environment_metrics_tag) {
|
||||
// Avoid sending 0s for sensors that could be 0
|
||||
if (decoded->variant.environment_metrics.has_temperature) {
|
||||
msgPayload["temperature"] = new JSONValue(decoded->variant.environment_metrics.temperature);
|
||||
msgPayload["temperature"] = decoded->variant.environment_metrics.temperature;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_relative_humidity) {
|
||||
msgPayload["relative_humidity"] = new JSONValue(decoded->variant.environment_metrics.relative_humidity);
|
||||
msgPayload["relative_humidity"] = decoded->variant.environment_metrics.relative_humidity;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_barometric_pressure) {
|
||||
msgPayload["barometric_pressure"] =
|
||||
new JSONValue(decoded->variant.environment_metrics.barometric_pressure);
|
||||
msgPayload["barometric_pressure"] = decoded->variant.environment_metrics.barometric_pressure;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_gas_resistance) {
|
||||
msgPayload["gas_resistance"] = new JSONValue(decoded->variant.environment_metrics.gas_resistance);
|
||||
msgPayload["gas_resistance"] = decoded->variant.environment_metrics.gas_resistance;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_voltage) {
|
||||
msgPayload["voltage"] = new JSONValue(decoded->variant.environment_metrics.voltage);
|
||||
msgPayload["voltage"] = decoded->variant.environment_metrics.voltage;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_current) {
|
||||
msgPayload["current"] = new JSONValue(decoded->variant.environment_metrics.current);
|
||||
msgPayload["current"] = decoded->variant.environment_metrics.current;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_lux) {
|
||||
msgPayload["lux"] = new JSONValue(decoded->variant.environment_metrics.lux);
|
||||
msgPayload["lux"] = decoded->variant.environment_metrics.lux;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_white_lux) {
|
||||
msgPayload["white_lux"] = new JSONValue(decoded->variant.environment_metrics.white_lux);
|
||||
msgPayload["white_lux"] = decoded->variant.environment_metrics.white_lux;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_iaq) {
|
||||
msgPayload["iaq"] = new JSONValue((uint)decoded->variant.environment_metrics.iaq);
|
||||
msgPayload["iaq"] = (Json::UInt)decoded->variant.environment_metrics.iaq;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_distance) {
|
||||
msgPayload["distance"] = new JSONValue(decoded->variant.environment_metrics.distance);
|
||||
msgPayload["distance"] = decoded->variant.environment_metrics.distance;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_speed) {
|
||||
msgPayload["wind_speed"] = new JSONValue(decoded->variant.environment_metrics.wind_speed);
|
||||
msgPayload["wind_speed"] = decoded->variant.environment_metrics.wind_speed;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_direction) {
|
||||
msgPayload["wind_direction"] = new JSONValue((uint)decoded->variant.environment_metrics.wind_direction);
|
||||
msgPayload["wind_direction"] = (Json::UInt)decoded->variant.environment_metrics.wind_direction;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_gust) {
|
||||
msgPayload["wind_gust"] = new JSONValue(decoded->variant.environment_metrics.wind_gust);
|
||||
msgPayload["wind_gust"] = decoded->variant.environment_metrics.wind_gust;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_lull) {
|
||||
msgPayload["wind_lull"] = new JSONValue(decoded->variant.environment_metrics.wind_lull);
|
||||
msgPayload["wind_lull"] = decoded->variant.environment_metrics.wind_lull;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_radiation) {
|
||||
msgPayload["radiation"] = new JSONValue(decoded->variant.environment_metrics.radiation);
|
||||
msgPayload["radiation"] = decoded->variant.environment_metrics.radiation;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_ir_lux) {
|
||||
msgPayload["ir_lux"] = new JSONValue(decoded->variant.environment_metrics.ir_lux);
|
||||
msgPayload["ir_lux"] = decoded->variant.environment_metrics.ir_lux;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_uv_lux) {
|
||||
msgPayload["uv_lux"] = new JSONValue(decoded->variant.environment_metrics.uv_lux);
|
||||
msgPayload["uv_lux"] = decoded->variant.environment_metrics.uv_lux;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_weight) {
|
||||
msgPayload["weight"] = new JSONValue(decoded->variant.environment_metrics.weight);
|
||||
msgPayload["weight"] = decoded->variant.environment_metrics.weight;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_rainfall_1h) {
|
||||
msgPayload["rainfall_1h"] = new JSONValue(decoded->variant.environment_metrics.rainfall_1h);
|
||||
msgPayload["rainfall_1h"] = decoded->variant.environment_metrics.rainfall_1h;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_rainfall_24h) {
|
||||
msgPayload["rainfall_24h"] = new JSONValue(decoded->variant.environment_metrics.rainfall_24h);
|
||||
msgPayload["rainfall_24h"] = decoded->variant.environment_metrics.rainfall_24h;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_soil_moisture) {
|
||||
msgPayload["soil_moisture"] = new JSONValue((uint)decoded->variant.environment_metrics.soil_moisture);
|
||||
msgPayload["soil_moisture"] = (Json::UInt)decoded->variant.environment_metrics.soil_moisture;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_soil_temperature) {
|
||||
msgPayload["soil_temperature"] = new JSONValue(decoded->variant.environment_metrics.soil_temperature);
|
||||
msgPayload["soil_temperature"] = decoded->variant.environment_metrics.soil_temperature;
|
||||
}
|
||||
} else if (decoded->which_variant == meshtastic_Telemetry_air_quality_metrics_tag) {
|
||||
if (decoded->variant.air_quality_metrics.has_pm10_standard) {
|
||||
msgPayload["pm10"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm10_standard);
|
||||
msgPayload["pm10"] = (Json::UInt)decoded->variant.air_quality_metrics.pm10_standard;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_pm25_standard) {
|
||||
msgPayload["pm25"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm25_standard);
|
||||
msgPayload["pm25"] = (Json::UInt)decoded->variant.air_quality_metrics.pm25_standard;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_pm100_standard) {
|
||||
msgPayload["pm100"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm100_standard);
|
||||
msgPayload["pm100"] = (Json::UInt)decoded->variant.air_quality_metrics.pm100_standard;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_co2) {
|
||||
msgPayload["co2"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.co2);
|
||||
msgPayload["co2"] = (Json::UInt)decoded->variant.air_quality_metrics.co2;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_co2_temperature) {
|
||||
msgPayload["co2_temperature"] = new JSONValue(decoded->variant.air_quality_metrics.co2_temperature);
|
||||
msgPayload["co2_temperature"] = decoded->variant.air_quality_metrics.co2_temperature;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_co2_humidity) {
|
||||
msgPayload["co2_humidity"] = new JSONValue(decoded->variant.air_quality_metrics.co2_humidity);
|
||||
msgPayload["co2_humidity"] = decoded->variant.air_quality_metrics.co2_humidity;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_form_formaldehyde) {
|
||||
msgPayload["form_formaldehyde"] = new JSONValue(decoded->variant.air_quality_metrics.form_formaldehyde);
|
||||
msgPayload["form_formaldehyde"] = decoded->variant.air_quality_metrics.form_formaldehyde;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_form_temperature) {
|
||||
msgPayload["form_temperature"] = new JSONValue(decoded->variant.air_quality_metrics.form_temperature);
|
||||
msgPayload["form_temperature"] = decoded->variant.air_quality_metrics.form_temperature;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_form_humidity) {
|
||||
msgPayload["form_humidity"] = new JSONValue(decoded->variant.air_quality_metrics.form_humidity);
|
||||
msgPayload["form_humidity"] = decoded->variant.air_quality_metrics.form_humidity;
|
||||
}
|
||||
} else if (decoded->which_variant == meshtastic_Telemetry_power_metrics_tag) {
|
||||
if (decoded->variant.power_metrics.has_ch1_voltage) {
|
||||
msgPayload["voltage_ch1"] = new JSONValue(decoded->variant.power_metrics.ch1_voltage);
|
||||
msgPayload["voltage_ch1"] = decoded->variant.power_metrics.ch1_voltage;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch1_current) {
|
||||
msgPayload["current_ch1"] = new JSONValue(decoded->variant.power_metrics.ch1_current);
|
||||
msgPayload["current_ch1"] = decoded->variant.power_metrics.ch1_current;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch2_voltage) {
|
||||
msgPayload["voltage_ch2"] = new JSONValue(decoded->variant.power_metrics.ch2_voltage);
|
||||
msgPayload["voltage_ch2"] = decoded->variant.power_metrics.ch2_voltage;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch2_current) {
|
||||
msgPayload["current_ch2"] = new JSONValue(decoded->variant.power_metrics.ch2_current);
|
||||
msgPayload["current_ch2"] = decoded->variant.power_metrics.ch2_current;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch3_voltage) {
|
||||
msgPayload["voltage_ch3"] = new JSONValue(decoded->variant.power_metrics.ch3_voltage);
|
||||
msgPayload["voltage_ch3"] = decoded->variant.power_metrics.ch3_voltage;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch3_current) {
|
||||
msgPayload["current_ch3"] = new JSONValue(decoded->variant.power_metrics.ch3_current);
|
||||
msgPayload["current_ch3"] = decoded->variant.power_metrics.ch3_current;
|
||||
}
|
||||
}
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, msgType.c_str());
|
||||
}
|
||||
@@ -200,12 +205,12 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_User_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
msgPayload["id"] = new JSONValue(decoded->id);
|
||||
msgPayload["longname"] = new JSONValue(decoded->long_name);
|
||||
msgPayload["shortname"] = new JSONValue(decoded->short_name);
|
||||
msgPayload["hardware"] = new JSONValue(decoded->hw_model);
|
||||
msgPayload["role"] = new JSONValue((int)decoded->role);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["id"] = decoded->id;
|
||||
msgPayload["longname"] = decoded->long_name;
|
||||
msgPayload["shortname"] = decoded->short_name;
|
||||
msgPayload["hardware"] = (int)decoded->hw_model;
|
||||
msgPayload["role"] = (int)decoded->role;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, msgType.c_str());
|
||||
}
|
||||
@@ -219,38 +224,38 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_Position_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
if ((int)decoded->time) {
|
||||
msgPayload["time"] = new JSONValue((unsigned int)decoded->time);
|
||||
msgPayload["time"] = (Json::UInt)decoded->time;
|
||||
}
|
||||
if ((int)decoded->timestamp) {
|
||||
msgPayload["timestamp"] = new JSONValue((unsigned int)decoded->timestamp);
|
||||
msgPayload["timestamp"] = (Json::UInt)decoded->timestamp;
|
||||
}
|
||||
msgPayload["latitude_i"] = new JSONValue((int)decoded->latitude_i);
|
||||
msgPayload["longitude_i"] = new JSONValue((int)decoded->longitude_i);
|
||||
msgPayload["latitude_i"] = (int)decoded->latitude_i;
|
||||
msgPayload["longitude_i"] = (int)decoded->longitude_i;
|
||||
if ((int)decoded->altitude) {
|
||||
msgPayload["altitude"] = new JSONValue((int)decoded->altitude);
|
||||
msgPayload["altitude"] = (int)decoded->altitude;
|
||||
}
|
||||
if ((int)decoded->ground_speed) {
|
||||
msgPayload["ground_speed"] = new JSONValue((unsigned int)decoded->ground_speed);
|
||||
msgPayload["ground_speed"] = (Json::UInt)decoded->ground_speed;
|
||||
}
|
||||
if (int(decoded->ground_track)) {
|
||||
msgPayload["ground_track"] = new JSONValue((unsigned int)decoded->ground_track);
|
||||
msgPayload["ground_track"] = (Json::UInt)decoded->ground_track;
|
||||
}
|
||||
if (int(decoded->sats_in_view)) {
|
||||
msgPayload["sats_in_view"] = new JSONValue((unsigned int)decoded->sats_in_view);
|
||||
msgPayload["sats_in_view"] = (Json::UInt)decoded->sats_in_view;
|
||||
}
|
||||
if ((int)decoded->PDOP) {
|
||||
msgPayload["PDOP"] = new JSONValue((int)decoded->PDOP);
|
||||
msgPayload["PDOP"] = (int)decoded->PDOP;
|
||||
}
|
||||
if ((int)decoded->HDOP) {
|
||||
msgPayload["HDOP"] = new JSONValue((int)decoded->HDOP);
|
||||
msgPayload["HDOP"] = (int)decoded->HDOP;
|
||||
}
|
||||
if ((int)decoded->VDOP) {
|
||||
msgPayload["VDOP"] = new JSONValue((int)decoded->VDOP);
|
||||
msgPayload["VDOP"] = (int)decoded->VDOP;
|
||||
}
|
||||
if ((int)decoded->precision_bits) {
|
||||
msgPayload["precision_bits"] = new JSONValue((int)decoded->precision_bits);
|
||||
msgPayload["precision_bits"] = (int)decoded->precision_bits;
|
||||
}
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, msgType.c_str());
|
||||
}
|
||||
@@ -263,14 +268,14 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_Waypoint_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
msgPayload["id"] = new JSONValue((unsigned int)decoded->id);
|
||||
msgPayload["name"] = new JSONValue(decoded->name);
|
||||
msgPayload["description"] = new JSONValue(decoded->description);
|
||||
msgPayload["expire"] = new JSONValue((unsigned int)decoded->expire);
|
||||
msgPayload["locked_to"] = new JSONValue((unsigned int)decoded->locked_to);
|
||||
msgPayload["latitude_i"] = new JSONValue((int)decoded->latitude_i);
|
||||
msgPayload["longitude_i"] = new JSONValue((int)decoded->longitude_i);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["id"] = (Json::UInt)decoded->id;
|
||||
msgPayload["name"] = decoded->name;
|
||||
msgPayload["description"] = decoded->description;
|
||||
msgPayload["expire"] = (Json::UInt)decoded->expire;
|
||||
msgPayload["locked_to"] = (Json::UInt)decoded->locked_to;
|
||||
msgPayload["latitude_i"] = (int)decoded->latitude_i;
|
||||
msgPayload["longitude_i"] = (int)decoded->longitude_i;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, msgType.c_str());
|
||||
}
|
||||
@@ -284,26 +289,26 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_NeighborInfo_msg,
|
||||
&scratch)) {
|
||||
decoded = &scratch;
|
||||
msgPayload["node_id"] = new JSONValue((unsigned int)decoded->node_id);
|
||||
msgPayload["node_broadcast_interval_secs"] = new JSONValue((unsigned int)decoded->node_broadcast_interval_secs);
|
||||
msgPayload["last_sent_by_id"] = new JSONValue((unsigned int)decoded->last_sent_by_id);
|
||||
msgPayload["neighbors_count"] = new JSONValue(decoded->neighbors_count);
|
||||
JSONArray neighbors;
|
||||
msgPayload["node_id"] = (Json::UInt)decoded->node_id;
|
||||
msgPayload["node_broadcast_interval_secs"] = (Json::UInt)decoded->node_broadcast_interval_secs;
|
||||
msgPayload["last_sent_by_id"] = (Json::UInt)decoded->last_sent_by_id;
|
||||
msgPayload["neighbors_count"] = (Json::UInt)decoded->neighbors_count;
|
||||
Json::Value neighbors(Json::arrayValue);
|
||||
for (uint8_t i = 0; i < decoded->neighbors_count; i++) {
|
||||
JSONObject neighborObj;
|
||||
neighborObj["node_id"] = new JSONValue((unsigned int)decoded->neighbors[i].node_id);
|
||||
neighborObj["snr"] = new JSONValue((int)decoded->neighbors[i].snr);
|
||||
neighbors.push_back(new JSONValue(neighborObj));
|
||||
Json::Value neighborObj(Json::objectValue);
|
||||
neighborObj["node_id"] = (Json::UInt)decoded->neighbors[i].node_id;
|
||||
neighborObj["snr"] = (int)decoded->neighbors[i].snr;
|
||||
neighbors.append(neighborObj);
|
||||
}
|
||||
msgPayload["neighbors"] = new JSONValue(neighbors);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["neighbors"] = neighbors;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, msgType.c_str());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_TRACEROUTE_APP: {
|
||||
if (mp->decoded.request_id) { // Only report the traceroute response
|
||||
if (mp->decoded.request_id) {
|
||||
msgType = "traceroute";
|
||||
meshtastic_RouteDiscovery scratch;
|
||||
meshtastic_RouteDiscovery *decoded = NULL;
|
||||
@@ -311,13 +316,12 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_RouteDiscovery_msg,
|
||||
&scratch)) {
|
||||
decoded = &scratch;
|
||||
JSONArray route; // Route this message took
|
||||
JSONArray routeBack; // Route this message took back
|
||||
JSONArray snrTowards; // Snr for forward route
|
||||
JSONArray snrBack; // Snr for reverse route
|
||||
Json::Value route(Json::arrayValue);
|
||||
Json::Value routeBack(Json::arrayValue);
|
||||
Json::Value snrTowards(Json::arrayValue);
|
||||
Json::Value snrBack(Json::arrayValue);
|
||||
|
||||
// Lambda function for adding a long name to the route
|
||||
auto addToRoute = [](JSONArray *route, NodeNum num) {
|
||||
auto addToRoute = [](Json::Value *r, NodeNum num) {
|
||||
char long_name[40] = "Unknown";
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num);
|
||||
bool name_known = nodeInfoLiteHasUser(node);
|
||||
@@ -327,33 +331,32 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
memcpy(long_name, node->long_name, copy_len);
|
||||
long_name[copy_len] = '\0';
|
||||
}
|
||||
route->push_back(new JSONValue(long_name));
|
||||
r->append(long_name);
|
||||
};
|
||||
addToRoute(&route, mp->to); // Started at the original transmitter (destination of response)
|
||||
addToRoute(&route, mp->to);
|
||||
for (uint8_t i = 0; i < decoded->route_count; i++) {
|
||||
addToRoute(&route, decoded->route[i]);
|
||||
}
|
||||
addToRoute(&route, mp->from); // Ended at the original destination (source of response)
|
||||
addToRoute(&route, mp->from);
|
||||
|
||||
addToRoute(&routeBack, mp->from); // Started at the original destination (source of response)
|
||||
addToRoute(&routeBack, mp->from);
|
||||
for (uint8_t i = 0; i < decoded->route_back_count; i++) {
|
||||
addToRoute(&routeBack, decoded->route_back[i]);
|
||||
}
|
||||
addToRoute(&routeBack, mp->to); // Ended at the original transmitter (destination of response)
|
||||
addToRoute(&routeBack, mp->to);
|
||||
|
||||
for (uint8_t i = 0; i < decoded->snr_back_count; i++) {
|
||||
snrBack.push_back(new JSONValue((float)decoded->snr_back[i] / 4));
|
||||
snrBack.append((float)decoded->snr_back[i] / 4);
|
||||
}
|
||||
|
||||
for (uint8_t i = 0; i < decoded->snr_towards_count; i++) {
|
||||
snrTowards.push_back(new JSONValue((float)decoded->snr_towards[i] / 4));
|
||||
snrTowards.append((float)decoded->snr_towards[i] / 4);
|
||||
}
|
||||
|
||||
msgPayload["route"] = new JSONValue(route);
|
||||
msgPayload["route_back"] = new JSONValue(routeBack);
|
||||
msgPayload["snr_back"] = new JSONValue(snrBack);
|
||||
msgPayload["snr_towards"] = new JSONValue(snrTowards);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["route"] = route;
|
||||
msgPayload["route_back"] = routeBack;
|
||||
msgPayload["snr_back"] = snrBack;
|
||||
msgPayload["snr_towards"] = snrTowards;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, msgType.c_str());
|
||||
}
|
||||
@@ -364,9 +367,9 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
msgType = "detection";
|
||||
char payloadStr[(mp->decoded.payload.size) + 1];
|
||||
memcpy(payloadStr, mp->decoded.payload.bytes, mp->decoded.payload.size);
|
||||
payloadStr[mp->decoded.payload.size] = 0; // null terminated string
|
||||
msgPayload["text"] = new JSONValue(payloadStr);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
payloadStr[mp->decoded.payload.size] = 0;
|
||||
msgPayload["text"] = payloadStr;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
break;
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
@@ -377,10 +380,10 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_Paxcount_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
msgPayload["wifi_count"] = new JSONValue((unsigned int)decoded->wifi);
|
||||
msgPayload["ble_count"] = new JSONValue((unsigned int)decoded->ble);
|
||||
msgPayload["uptime"] = new JSONValue((unsigned int)decoded->uptime);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["wifi_count"] = (Json::UInt)decoded->wifi;
|
||||
msgPayload["ble_count"] = (Json::UInt)decoded->ble;
|
||||
msgPayload["uptime"] = (Json::UInt)decoded->uptime;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, msgType.c_str());
|
||||
}
|
||||
@@ -396,20 +399,19 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
decoded = &scratch;
|
||||
if (decoded->type == meshtastic_HardwareMessage_Type_GPIOS_CHANGED) {
|
||||
msgType = "gpios_changed";
|
||||
msgPayload["gpio_value"] = new JSONValue((unsigned int)decoded->gpio_value);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["gpio_value"] = (Json::UInt)decoded->gpio_value;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
} else if (decoded->type == meshtastic_HardwareMessage_Type_READ_GPIOS_REPLY) {
|
||||
msgType = "gpios_read_reply";
|
||||
msgPayload["gpio_value"] = new JSONValue((unsigned int)decoded->gpio_value);
|
||||
msgPayload["gpio_mask"] = new JSONValue((unsigned int)decoded->gpio_mask);
|
||||
jsonObj["payload"] = new JSONValue(msgPayload);
|
||||
msgPayload["gpio_value"] = (Json::UInt)decoded->gpio_value;
|
||||
msgPayload["gpio_mask"] = (Json::UInt)decoded->gpio_mask;
|
||||
jsonObj["payload"] = msgPayload;
|
||||
}
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR(errStr, "RemoteHardware");
|
||||
}
|
||||
break;
|
||||
}
|
||||
// add more packet types here if needed
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -417,64 +419,56 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
|
||||
LOG_WARN("Couldn't convert encrypted payload of MeshPacket to JSON");
|
||||
}
|
||||
|
||||
jsonObj["id"] = new JSONValue((unsigned int)mp->id);
|
||||
jsonObj["timestamp"] = new JSONValue((unsigned int)mp->rx_time);
|
||||
jsonObj["to"] = new JSONValue((unsigned int)mp->to);
|
||||
jsonObj["from"] = new JSONValue((unsigned int)mp->from);
|
||||
jsonObj["channel"] = new JSONValue((unsigned int)mp->channel);
|
||||
jsonObj["type"] = new JSONValue(msgType.c_str());
|
||||
jsonObj["sender"] = new JSONValue(nodeDB->getNodeId().c_str());
|
||||
jsonObj["id"] = (Json::UInt)mp->id;
|
||||
jsonObj["timestamp"] = (Json::UInt)mp->rx_time;
|
||||
jsonObj["to"] = (Json::UInt)mp->to;
|
||||
jsonObj["from"] = (Json::UInt)mp->from;
|
||||
jsonObj["channel"] = (Json::UInt)mp->channel;
|
||||
jsonObj["type"] = msgType;
|
||||
jsonObj["sender"] = nodeDB->getNodeId();
|
||||
if (mp->rx_rssi != 0)
|
||||
jsonObj["rssi"] = new JSONValue((int)mp->rx_rssi);
|
||||
jsonObj["rssi"] = (int)mp->rx_rssi;
|
||||
if (mp->rx_snr != 0)
|
||||
jsonObj["snr"] = new JSONValue((float)mp->rx_snr);
|
||||
jsonObj["snr"] = (float)mp->rx_snr;
|
||||
const int8_t hopsAway = getHopsAway(*mp);
|
||||
if (hopsAway >= 0) {
|
||||
jsonObj["hops_away"] = new JSONValue((unsigned int)(hopsAway));
|
||||
jsonObj["hop_start"] = new JSONValue((unsigned int)(mp->hop_start));
|
||||
jsonObj["hops_away"] = (Json::UInt)(hopsAway);
|
||||
jsonObj["hop_start"] = (Json::UInt)(mp->hop_start);
|
||||
}
|
||||
|
||||
// serialize and write it to the stream
|
||||
JSONValue *value = new JSONValue(jsonObj);
|
||||
std::string jsonStr = value->Stringify();
|
||||
std::string jsonStr = writeCompact(jsonObj);
|
||||
|
||||
if (shouldLog)
|
||||
LOG_INFO("serialized json message: %s", jsonStr.c_str());
|
||||
|
||||
delete value;
|
||||
return jsonStr;
|
||||
}
|
||||
|
||||
std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPacket *mp)
|
||||
{
|
||||
JSONObject jsonObj;
|
||||
Json::Value jsonObj(Json::objectValue);
|
||||
|
||||
jsonObj["id"] = new JSONValue((unsigned int)mp->id);
|
||||
jsonObj["time_ms"] = new JSONValue((double)millis());
|
||||
jsonObj["timestamp"] = new JSONValue((unsigned int)mp->rx_time);
|
||||
jsonObj["to"] = new JSONValue((unsigned int)mp->to);
|
||||
jsonObj["from"] = new JSONValue((unsigned int)mp->from);
|
||||
jsonObj["channel"] = new JSONValue((unsigned int)mp->channel);
|
||||
jsonObj["want_ack"] = new JSONValue(mp->want_ack);
|
||||
jsonObj["id"] = (Json::UInt)mp->id;
|
||||
jsonObj["time_ms"] = (double)millis();
|
||||
jsonObj["timestamp"] = (Json::UInt)mp->rx_time;
|
||||
jsonObj["to"] = (Json::UInt)mp->to;
|
||||
jsonObj["from"] = (Json::UInt)mp->from;
|
||||
jsonObj["channel"] = (Json::UInt)mp->channel;
|
||||
jsonObj["want_ack"] = mp->want_ack;
|
||||
|
||||
if (mp->rx_rssi != 0)
|
||||
jsonObj["rssi"] = new JSONValue((int)mp->rx_rssi);
|
||||
jsonObj["rssi"] = (int)mp->rx_rssi;
|
||||
if (mp->rx_snr != 0)
|
||||
jsonObj["snr"] = new JSONValue((float)mp->rx_snr);
|
||||
jsonObj["snr"] = (float)mp->rx_snr;
|
||||
const int8_t hopsAway = getHopsAway(*mp);
|
||||
if (hopsAway >= 0) {
|
||||
jsonObj["hops_away"] = new JSONValue((unsigned int)(hopsAway));
|
||||
jsonObj["hop_start"] = new JSONValue((unsigned int)(mp->hop_start));
|
||||
jsonObj["hops_away"] = (Json::UInt)(hopsAway);
|
||||
jsonObj["hop_start"] = (Json::UInt)(mp->hop_start);
|
||||
}
|
||||
jsonObj["size"] = new JSONValue((unsigned int)mp->encrypted.size);
|
||||
jsonObj["size"] = (Json::UInt)mp->encrypted.size;
|
||||
auto encryptedStr = bytesToHex(mp->encrypted.bytes, mp->encrypted.size);
|
||||
jsonObj["bytes"] = new JSONValue(encryptedStr.c_str());
|
||||
jsonObj["bytes"] = encryptedStr;
|
||||
|
||||
// serialize and write it to the stream
|
||||
JSONValue *value = new JSONValue(jsonObj);
|
||||
std::string jsonStr = value->Stringify();
|
||||
|
||||
delete value;
|
||||
return jsonStr;
|
||||
return writeCompact(jsonObj);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
#ifdef NRF52_USE_JSON
|
||||
#warning 'Using nRF52 Serializer'
|
||||
|
||||
#include "ArduinoJson.h"
|
||||
#include "MeshPacketSerializer.h"
|
||||
#include "NodeDB.h"
|
||||
#include "mesh/generated/meshtastic/mqtt.pb.h"
|
||||
#include "mesh/generated/meshtastic/remote_hardware.pb.h"
|
||||
#include "mesh/generated/meshtastic/telemetry.pb.h"
|
||||
#include "modules/RoutingModule.h"
|
||||
#include <DebugConfiguration.h>
|
||||
#include <mesh-pb-constants.h>
|
||||
|
||||
StaticJsonDocument<1024> jsonObj;
|
||||
StaticJsonDocument<1024> arrayObj;
|
||||
|
||||
std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, bool shouldLog)
|
||||
{
|
||||
// the created jsonObj is immutable after creation, so
|
||||
// we need to do the heavy lifting before assembling it.
|
||||
std::string msgType;
|
||||
jsonObj.clear();
|
||||
arrayObj.clear();
|
||||
|
||||
if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
|
||||
switch (mp->decoded.portnum) {
|
||||
case meshtastic_PortNum_TEXT_MESSAGE_APP: {
|
||||
msgType = "text";
|
||||
// convert bytes to string
|
||||
if (shouldLog)
|
||||
LOG_DEBUG("got text message of size %u", mp->decoded.payload.size);
|
||||
|
||||
char payloadStr[(mp->decoded.payload.size) + 1];
|
||||
memcpy(payloadStr, mp->decoded.payload.bytes, mp->decoded.payload.size);
|
||||
payloadStr[mp->decoded.payload.size] = 0; // null terminated string
|
||||
// check if this is a JSON payload
|
||||
StaticJsonDocument<512> text_doc;
|
||||
DeserializationError error = deserializeJson(text_doc, payloadStr);
|
||||
if (error) {
|
||||
// if it isn't, then we need to create a json object
|
||||
// with the string as the value
|
||||
if (shouldLog)
|
||||
LOG_INFO("text message payload is of type plaintext");
|
||||
jsonObj["payload"]["text"] = payloadStr;
|
||||
} else {
|
||||
// if it is, then we can just use the json object
|
||||
if (shouldLog)
|
||||
LOG_INFO("text message payload is of type json");
|
||||
jsonObj["payload"] = text_doc;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_TELEMETRY_APP: {
|
||||
msgType = "telemetry";
|
||||
meshtastic_Telemetry scratch;
|
||||
meshtastic_Telemetry *decoded = NULL;
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_Telemetry_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
if (decoded->which_variant == meshtastic_Telemetry_device_metrics_tag) {
|
||||
// If battery is present, encode the battery level value
|
||||
// TODO - Add a condition to send a code for a non-present value
|
||||
if (decoded->variant.device_metrics.has_battery_level) {
|
||||
jsonObj["payload"]["battery_level"] = (int)decoded->variant.device_metrics.battery_level;
|
||||
}
|
||||
jsonObj["payload"]["voltage"] = decoded->variant.device_metrics.voltage;
|
||||
jsonObj["payload"]["channel_utilization"] = decoded->variant.device_metrics.channel_utilization;
|
||||
jsonObj["payload"]["air_util_tx"] = decoded->variant.device_metrics.air_util_tx;
|
||||
jsonObj["payload"]["uptime_seconds"] = (unsigned int)decoded->variant.device_metrics.uptime_seconds;
|
||||
} else if (decoded->which_variant == meshtastic_Telemetry_environment_metrics_tag) {
|
||||
if (decoded->variant.environment_metrics.has_temperature) {
|
||||
jsonObj["payload"]["temperature"] = decoded->variant.environment_metrics.temperature;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_relative_humidity) {
|
||||
jsonObj["payload"]["relative_humidity"] = decoded->variant.environment_metrics.relative_humidity;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_barometric_pressure) {
|
||||
jsonObj["payload"]["barometric_pressure"] = decoded->variant.environment_metrics.barometric_pressure;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_gas_resistance) {
|
||||
jsonObj["payload"]["gas_resistance"] = decoded->variant.environment_metrics.gas_resistance;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_voltage) {
|
||||
jsonObj["payload"]["voltage"] = decoded->variant.environment_metrics.voltage;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_current) {
|
||||
jsonObj["payload"]["current"] = decoded->variant.environment_metrics.current;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_lux) {
|
||||
jsonObj["payload"]["lux"] = decoded->variant.environment_metrics.lux;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_white_lux) {
|
||||
jsonObj["payload"]["white_lux"] = decoded->variant.environment_metrics.white_lux;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_iaq) {
|
||||
jsonObj["payload"]["iaq"] = (uint)decoded->variant.environment_metrics.iaq;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_speed) {
|
||||
jsonObj["payload"]["wind_speed"] = decoded->variant.environment_metrics.wind_speed;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_direction) {
|
||||
jsonObj["payload"]["wind_direction"] = (uint)decoded->variant.environment_metrics.wind_direction;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_gust) {
|
||||
jsonObj["payload"]["wind_gust"] = decoded->variant.environment_metrics.wind_gust;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_wind_lull) {
|
||||
jsonObj["payload"]["wind_lull"] = decoded->variant.environment_metrics.wind_lull;
|
||||
}
|
||||
if (decoded->variant.environment_metrics.has_radiation) {
|
||||
jsonObj["payload"]["radiation"] = decoded->variant.environment_metrics.radiation;
|
||||
}
|
||||
} else if (decoded->which_variant == meshtastic_Telemetry_air_quality_metrics_tag) {
|
||||
if (decoded->variant.air_quality_metrics.has_pm10_standard) {
|
||||
jsonObj["payload"]["pm10"] = (unsigned int)decoded->variant.air_quality_metrics.pm10_standard;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_pm25_standard) {
|
||||
jsonObj["payload"]["pm25"] = (unsigned int)decoded->variant.air_quality_metrics.pm25_standard;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_pm100_standard) {
|
||||
jsonObj["payload"]["pm100"] = (unsigned int)decoded->variant.air_quality_metrics.pm100_standard;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_co2) {
|
||||
jsonObj["payload"]["co2"] = (unsigned int)decoded->variant.air_quality_metrics.co2;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_co2_temperature) {
|
||||
jsonObj["payload"]["co2_temperature"] = decoded->variant.air_quality_metrics.co2_temperature;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_co2_humidity) {
|
||||
jsonObj["payload"]["co2_humidity"] = decoded->variant.air_quality_metrics.co2_humidity;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_form_formaldehyde) {
|
||||
jsonObj["payload"]["form_formaldehyde"] = decoded->variant.air_quality_metrics.form_formaldehyde;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_form_temperature) {
|
||||
jsonObj["payload"]["form_temperature"] = decoded->variant.air_quality_metrics.form_temperature;
|
||||
}
|
||||
if (decoded->variant.air_quality_metrics.has_form_humidity) {
|
||||
jsonObj["payload"]["form_humidity"] = decoded->variant.air_quality_metrics.form_humidity;
|
||||
}
|
||||
} else if (decoded->which_variant == meshtastic_Telemetry_power_metrics_tag) {
|
||||
if (decoded->variant.power_metrics.has_ch1_voltage) {
|
||||
jsonObj["payload"]["voltage_ch1"] = decoded->variant.power_metrics.ch1_voltage;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch1_current) {
|
||||
jsonObj["payload"]["current_ch1"] = decoded->variant.power_metrics.ch1_current;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch2_voltage) {
|
||||
jsonObj["payload"]["voltage_ch2"] = decoded->variant.power_metrics.ch2_voltage;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch2_current) {
|
||||
jsonObj["payload"]["current_ch2"] = decoded->variant.power_metrics.ch2_current;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch3_voltage) {
|
||||
jsonObj["payload"]["voltage_ch3"] = decoded->variant.power_metrics.ch3_voltage;
|
||||
}
|
||||
if (decoded->variant.power_metrics.has_ch3_current) {
|
||||
jsonObj["payload"]["current_ch3"] = decoded->variant.power_metrics.ch3_current;
|
||||
}
|
||||
}
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR("Error decoding proto for telemetry message!");
|
||||
return "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_NODEINFO_APP: {
|
||||
msgType = "nodeinfo";
|
||||
meshtastic_User scratch;
|
||||
meshtastic_User *decoded = NULL;
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_User_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
jsonObj["payload"]["id"] = decoded->id;
|
||||
jsonObj["payload"]["longname"] = decoded->long_name;
|
||||
jsonObj["payload"]["shortname"] = decoded->short_name;
|
||||
jsonObj["payload"]["hardware"] = decoded->hw_model;
|
||||
jsonObj["payload"]["role"] = (int)decoded->role;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR("Error decoding proto for nodeinfo message!");
|
||||
return "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_POSITION_APP: {
|
||||
msgType = "position";
|
||||
meshtastic_Position scratch;
|
||||
meshtastic_Position *decoded = NULL;
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_Position_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
if ((int)decoded->time) {
|
||||
jsonObj["payload"]["time"] = (unsigned int)decoded->time;
|
||||
}
|
||||
if ((int)decoded->timestamp) {
|
||||
jsonObj["payload"]["timestamp"] = (unsigned int)decoded->timestamp;
|
||||
}
|
||||
jsonObj["payload"]["latitude_i"] = (int)decoded->latitude_i;
|
||||
jsonObj["payload"]["longitude_i"] = (int)decoded->longitude_i;
|
||||
if ((int)decoded->altitude) {
|
||||
jsonObj["payload"]["altitude"] = (int)decoded->altitude;
|
||||
}
|
||||
if ((int)decoded->ground_speed) {
|
||||
jsonObj["payload"]["ground_speed"] = (unsigned int)decoded->ground_speed;
|
||||
}
|
||||
if (int(decoded->ground_track)) {
|
||||
jsonObj["payload"]["ground_track"] = (unsigned int)decoded->ground_track;
|
||||
}
|
||||
if (int(decoded->sats_in_view)) {
|
||||
jsonObj["payload"]["sats_in_view"] = (unsigned int)decoded->sats_in_view;
|
||||
}
|
||||
if ((int)decoded->PDOP) {
|
||||
jsonObj["payload"]["PDOP"] = (int)decoded->PDOP;
|
||||
}
|
||||
if ((int)decoded->HDOP) {
|
||||
jsonObj["payload"]["HDOP"] = (int)decoded->HDOP;
|
||||
}
|
||||
if ((int)decoded->VDOP) {
|
||||
jsonObj["payload"]["VDOP"] = (int)decoded->VDOP;
|
||||
}
|
||||
if ((int)decoded->precision_bits) {
|
||||
jsonObj["payload"]["precision_bits"] = (int)decoded->precision_bits;
|
||||
}
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR("Error decoding proto for position message!");
|
||||
return "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_WAYPOINT_APP: {
|
||||
msgType = "position";
|
||||
meshtastic_Waypoint scratch;
|
||||
meshtastic_Waypoint *decoded = NULL;
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_Waypoint_msg, &scratch)) {
|
||||
decoded = &scratch;
|
||||
jsonObj["payload"]["id"] = (unsigned int)decoded->id;
|
||||
jsonObj["payload"]["name"] = decoded->name;
|
||||
jsonObj["payload"]["description"] = decoded->description;
|
||||
jsonObj["payload"]["expire"] = (unsigned int)decoded->expire;
|
||||
jsonObj["payload"]["locked_to"] = (unsigned int)decoded->locked_to;
|
||||
jsonObj["payload"]["latitude_i"] = (int)decoded->latitude_i;
|
||||
jsonObj["payload"]["longitude_i"] = (int)decoded->longitude_i;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR("Error decoding proto for position message!");
|
||||
return "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_NEIGHBORINFO_APP: {
|
||||
msgType = "neighborinfo";
|
||||
meshtastic_NeighborInfo scratch;
|
||||
meshtastic_NeighborInfo *decoded = NULL;
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_NeighborInfo_msg,
|
||||
&scratch)) {
|
||||
decoded = &scratch;
|
||||
jsonObj["payload"]["node_id"] = (unsigned int)decoded->node_id;
|
||||
jsonObj["payload"]["node_broadcast_interval_secs"] = (unsigned int)decoded->node_broadcast_interval_secs;
|
||||
jsonObj["payload"]["last_sent_by_id"] = (unsigned int)decoded->last_sent_by_id;
|
||||
jsonObj["payload"]["neighbors_count"] = decoded->neighbors_count;
|
||||
|
||||
JsonObject neighbors_obj = arrayObj.to<JsonObject>();
|
||||
JsonArray neighbors = neighbors_obj.createNestedArray("neighbors");
|
||||
JsonObject neighbors_0 = neighbors.createNestedObject();
|
||||
|
||||
for (uint8_t i = 0; i < decoded->neighbors_count; i++) {
|
||||
neighbors_0["node_id"] = (unsigned int)decoded->neighbors[i].node_id;
|
||||
neighbors_0["snr"] = (int)decoded->neighbors[i].snr;
|
||||
neighbors[i + 1] = neighbors_0;
|
||||
neighbors_0.clear();
|
||||
}
|
||||
neighbors.remove(0);
|
||||
jsonObj["payload"]["neighbors"] = neighbors;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR("Error decoding proto for neighborinfo message!");
|
||||
return "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_TRACEROUTE_APP: {
|
||||
if (mp->decoded.request_id) { // Only report the traceroute response
|
||||
msgType = "traceroute";
|
||||
meshtastic_RouteDiscovery scratch;
|
||||
meshtastic_RouteDiscovery *decoded = NULL;
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_RouteDiscovery_msg,
|
||||
&scratch)) {
|
||||
decoded = &scratch;
|
||||
JsonArray route = arrayObj.createNestedArray("route");
|
||||
|
||||
auto addToRoute = [](JsonArray *route, NodeNum num) {
|
||||
char long_name[40] = "Unknown";
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num);
|
||||
bool name_known = nodeInfoLiteHasUser(node);
|
||||
if (name_known) {
|
||||
const size_t copy_len =
|
||||
(sizeof(node->long_name) < sizeof(long_name)) ? sizeof(node->long_name) : sizeof(long_name) - 1;
|
||||
memcpy(long_name, node->long_name, copy_len);
|
||||
long_name[copy_len] = '\0';
|
||||
}
|
||||
route->add(long_name);
|
||||
};
|
||||
|
||||
addToRoute(&route, mp->to); // route.add(mp->to);
|
||||
for (uint8_t i = 0; i < decoded->route_count; i++) {
|
||||
addToRoute(&route, decoded->route[i]); // route.add(decoded->route[i]);
|
||||
}
|
||||
addToRoute(&route,
|
||||
mp->from); // route.add(mp->from); // Ended at the original destination (source of response)
|
||||
|
||||
jsonObj["payload"]["route"] = route;
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR("Error decoding proto for traceroute message!");
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
LOG_WARN("Traceroute response not reported");
|
||||
return "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_DETECTION_SENSOR_APP: {
|
||||
msgType = "detection";
|
||||
char payloadStr[(mp->decoded.payload.size) + 1];
|
||||
memcpy(payloadStr, mp->decoded.payload.bytes, mp->decoded.payload.size);
|
||||
payloadStr[mp->decoded.payload.size] = 0; // null terminated string
|
||||
jsonObj["payload"]["text"] = payloadStr;
|
||||
break;
|
||||
}
|
||||
case meshtastic_PortNum_REMOTE_HARDWARE_APP: {
|
||||
meshtastic_HardwareMessage scratch;
|
||||
meshtastic_HardwareMessage *decoded = NULL;
|
||||
memset(&scratch, 0, sizeof(scratch));
|
||||
if (pb_decode_from_bytes(mp->decoded.payload.bytes, mp->decoded.payload.size, &meshtastic_HardwareMessage_msg,
|
||||
&scratch)) {
|
||||
decoded = &scratch;
|
||||
if (decoded->type == meshtastic_HardwareMessage_Type_GPIOS_CHANGED) {
|
||||
msgType = "gpios_changed";
|
||||
jsonObj["payload"]["gpio_value"] = (unsigned int)decoded->gpio_value;
|
||||
} else if (decoded->type == meshtastic_HardwareMessage_Type_READ_GPIOS_REPLY) {
|
||||
msgType = "gpios_read_reply";
|
||||
jsonObj["payload"]["gpio_value"] = (unsigned int)decoded->gpio_value;
|
||||
jsonObj["payload"]["gpio_mask"] = (unsigned int)decoded->gpio_mask;
|
||||
}
|
||||
} else if (shouldLog) {
|
||||
LOG_ERROR("Error decoding proto for RemoteHardware message!");
|
||||
return "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
// add more packet types here if needed
|
||||
default:
|
||||
LOG_WARN("Unsupported packet type %d", mp->decoded.portnum);
|
||||
return "";
|
||||
break;
|
||||
}
|
||||
} else if (shouldLog) {
|
||||
LOG_WARN("Couldn't convert encrypted payload of MeshPacket to JSON");
|
||||
return "";
|
||||
}
|
||||
|
||||
jsonObj["id"] = (unsigned int)mp->id;
|
||||
jsonObj["timestamp"] = (unsigned int)mp->rx_time;
|
||||
jsonObj["to"] = (unsigned int)mp->to;
|
||||
jsonObj["from"] = (unsigned int)mp->from;
|
||||
jsonObj["channel"] = (unsigned int)mp->channel;
|
||||
jsonObj["type"] = msgType.c_str();
|
||||
jsonObj["sender"] = nodeDB->getNodeId().c_str();
|
||||
if (mp->rx_rssi != 0)
|
||||
jsonObj["rssi"] = (int)mp->rx_rssi;
|
||||
if (mp->rx_snr != 0)
|
||||
jsonObj["snr"] = (float)mp->rx_snr;
|
||||
const int8_t hopsAway = getHopsAway(*mp);
|
||||
if (hopsAway >= 0) {
|
||||
jsonObj["hops_away"] = (unsigned int)(hopsAway);
|
||||
jsonObj["hop_start"] = (unsigned int)(mp->hop_start);
|
||||
}
|
||||
|
||||
// serialize and write it to the stream
|
||||
|
||||
// Serial.printf("serialized json message: \r");
|
||||
// serializeJson(jsonObj, Serial);
|
||||
// Serial.println("");
|
||||
|
||||
std::string jsonStr = "";
|
||||
serializeJson(jsonObj, jsonStr);
|
||||
|
||||
if (shouldLog)
|
||||
LOG_INFO("serialized json message: %s", jsonStr.c_str());
|
||||
|
||||
return jsonStr;
|
||||
}
|
||||
|
||||
std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPacket *mp)
|
||||
{
|
||||
jsonObj.clear();
|
||||
jsonObj["id"] = (unsigned int)mp->id;
|
||||
jsonObj["time_ms"] = (double)millis();
|
||||
jsonObj["timestamp"] = (unsigned int)mp->rx_time;
|
||||
jsonObj["to"] = (unsigned int)mp->to;
|
||||
jsonObj["from"] = (unsigned int)mp->from;
|
||||
jsonObj["channel"] = (unsigned int)mp->channel;
|
||||
jsonObj["want_ack"] = mp->want_ack;
|
||||
|
||||
if (mp->rx_rssi != 0)
|
||||
jsonObj["rssi"] = (int)mp->rx_rssi;
|
||||
if (mp->rx_snr != 0)
|
||||
jsonObj["snr"] = (float)mp->rx_snr;
|
||||
const int8_t hopsAway = getHopsAway(*mp);
|
||||
if (hopsAway >= 0) {
|
||||
jsonObj["hops_away"] = (unsigned int)(hopsAway);
|
||||
jsonObj["hop_start"] = (unsigned int)(mp->hop_start);
|
||||
}
|
||||
jsonObj["size"] = (unsigned int)mp->encrypted.size;
|
||||
auto encryptedStr = bytesToHex(mp->encrypted.bytes, mp->encrypted.size);
|
||||
jsonObj["bytes"] = encryptedStr.c_str();
|
||||
|
||||
// serialize and write it to the stream
|
||||
std::string jsonStr = "";
|
||||
serializeJson(jsonObj, jsonStr);
|
||||
|
||||
return jsonStr;
|
||||
}
|
||||
#endif
|
||||
+88
-14
@@ -15,6 +15,38 @@ pio test -e native -f test_your_module
|
||||
pio test -e native -f test_your_module -vvv
|
||||
```
|
||||
|
||||
**Never pipe through `| tail -N` to shorten output.** PlatformIO prints build errors at the top of output and test results at the bottom; `tail` will show stale cached results from a prior successful build while hiding the compile error that caused the current run to fail.
|
||||
|
||||
**Preferred pattern — redirect to file, then grep:**
|
||||
|
||||
```bash
|
||||
# Redirect all output to a file; grep for errors and results after it exits
|
||||
pio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
|
||||
echo "exit: $?"
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
Why: piping through `| grep` line-buffers the output and suppresses all progress until the process exits, making it look hung. The redirect approach lets the build stream normally while still giving you filtered results afterwards.
|
||||
|
||||
**Viewing verbose test output without truncation (e.g. `TEST_MESSAGE` group headers):**
|
||||
|
||||
```bash
|
||||
/tmp/meshtastic-pio-venv/bin/python -m platformio test -e coverage --filter test_mesh_beacon -vv 2>&1 | grep -v "[[:space:]]SKIPPED$"
|
||||
```
|
||||
|
||||
The `-vv` flag makes Unity emit `INFO:` lines from `TEST_MESSAGE` calls; piping through `grep -v SKIPPED` removes the noise from platform feature gates while keeping all PASS/FAIL/INFO lines visible.
|
||||
|
||||
**`externally-managed-environment` error on Ubuntu/Debian:**
|
||||
|
||||
If `pio test` fails immediately with `error: externally-managed-environment`, the system `pio` binary is using the OS Python which newer distros lock down. Use PlatformIO's own venv instead:
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
### Helper Scripts (Useful Shortcuts)
|
||||
|
||||
These wrappers are handy when local host dependencies are missing or when you want repeatable commands.
|
||||
@@ -82,12 +114,15 @@ One file per suite. No per-test `platformio.ini` is needed — tests build under
|
||||
#include "gps/RTC.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "modules/YourModule.h"
|
||||
#include <cstdio>
|
||||
#include <cstdio> // required for printf() — used for blank-line group separators
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
// --- Test output helpers ---
|
||||
// Unity swallows printf/stdout. Only TEST_MESSAGE() output appears in results.
|
||||
// printf() writes directly to stdout and appears in -vv output as a plain line (no prefix).
|
||||
// Use it for blank-line group separators: printf("\n");
|
||||
// TEST_MESSAGE() emits a "file:line:INFO: <text>" line — visible at -vv and above.
|
||||
// Use TEST_MSG_FMT for formatted diagnostic lines inside tests.
|
||||
#define MSG_BUF_LEN 200
|
||||
#define TEST_MSG_FMT(fmt, ...) do { \
|
||||
char _buf[MSG_BUF_LEN]; \
|
||||
@@ -112,6 +147,9 @@ void setup()
|
||||
{
|
||||
initializeTestEnvironment(); // MUST call — sets up RTC, OSThread, console
|
||||
UNITY_BEGIN();
|
||||
|
||||
printf("\n=== Example group ===\n"); // header line to help find tests
|
||||
|
||||
RUN_TEST(test_example);
|
||||
exit(UNITY_END()); // exit() required — Unity runner expects it
|
||||
}
|
||||
@@ -162,7 +200,7 @@ class MockNodeDB : public NodeDB
|
||||
node.num = num;
|
||||
node.has_hops_away = hasHops;
|
||||
node.hops_away = hopsAway;
|
||||
node.via_mqtt = viaMqtt;
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
|
||||
node.last_heard = getTime() - ageSecs;
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
@@ -185,20 +223,34 @@ Subclass the module under test to make protected methods callable and private me
|
||||
class YourModuleTestShim : public YourModule
|
||||
{
|
||||
public:
|
||||
// Expose protected methods
|
||||
// Pull protected methods into public scope via using.
|
||||
// IMPORTANT: using requires the method to be protected (or public) in the base —
|
||||
// friend alone does NOT satisfy this. See pitfall #6.
|
||||
using YourModule::runOnce;
|
||||
using YourModule::someProtectedMethod;
|
||||
|
||||
// Access private members via friend (see below)
|
||||
// Wrap private members with setter methods (friend grants direct access here).
|
||||
void setPrivateField(int x) { privateField = x; }
|
||||
};
|
||||
```
|
||||
|
||||
In the module header, grant friend access under the `UNIT_TEST` define (set automatically by PlatformIO's test framework):
|
||||
For methods you want to expose via `using`, use the conditional access-specifier pattern in the header — **not** plain `friend`:
|
||||
|
||||
```cpp
|
||||
// In YourModule.h, inside the class body:
|
||||
#ifdef UNIT_TEST
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
protected:
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
bool someMethod();
|
||||
```
|
||||
|
||||
For private _member variables_ that a shim setter needs to touch directly, `friend` is sufficient (no `using` involved):
|
||||
|
||||
```cpp
|
||||
// In YourModule.h, inside the class body:
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
friend class YourModuleTestShim;
|
||||
#endif
|
||||
```
|
||||
@@ -284,6 +336,21 @@ Fixed-size data structures (hash sets, ring buffers) overflow when tests inject
|
||||
|
||||
**Fix:** Simulate multiple realistic time windows rather than one massive burst. Let adaptive mechanisms (if any) self-tune over several rolls.
|
||||
|
||||
### 6. Granting test access to private/protected members
|
||||
|
||||
PlatformIO defines `PIO_UNIT_TESTING` during `pio test` builds. Several production headers (`TransmitHistory.h`, `CryptoEngine.h`, `MQTT.h`, `RTC.h`) use this to gate test-only visibility changes. PlatformIO also defines `UNIT_TEST` in the same builds for backward compatibility, but that spelling is deprecated — always use `PIO_UNIT_TESTING` in new code. The established pattern for exposing a private method to a test shim **without widening production visibility**:
|
||||
|
||||
```cpp
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
protected:
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
bool myMethod();
|
||||
```
|
||||
|
||||
**Critical C++ rule:** a `using` declaration in a derived class (e.g. `using Base::myMethod`) requires `myMethod` to be `protected` or `public` in the base — `friend` alone does **not** satisfy this. Adding `friend class TestShim` while leaving the method `private` will still fail to compile. Use the conditional access-specifier pattern above, not `friend`.
|
||||
|
||||
## setUp/tearDown Checklist
|
||||
|
||||
- [ ] Create and clear MockNodeDB (if needed)
|
||||
@@ -291,6 +358,7 @@ Fixed-size data structures (hash sets, ring buffers) overflow when tests inject
|
||||
- [ ] Set `nodeDB = mockNodeDB`
|
||||
- [ ] Delete persisted state files (`FSCom.remove(...)`)
|
||||
- [ ] Reset file-scope mutable globals
|
||||
- [ ] Reset mock clock to a safe base value (e.g. `mockTime = ONE_HOUR_MS`) — prevents unsigned subtraction underflow in time-dependent logic
|
||||
- [ ] Disable randomness/jitter flags
|
||||
- [ ] In `tearDown`: null the global singleton pointer, restore flags
|
||||
|
||||
@@ -308,15 +376,21 @@ A well-structured test suite follows this pattern:
|
||||
|
||||
| Suite | Module Under Test |
|
||||
| ---------------------------- | ----------------------------- |
|
||||
| `test_admin_radio` | Admin + LoRa region config |
|
||||
| `test_atak` | ATAK integration |
|
||||
| `test_crypto` | CryptoEngine |
|
||||
| `test_mqtt` | MQTT integration |
|
||||
| `test_radio` | Radio interface |
|
||||
| `test_default` | Default configuration helpers |
|
||||
| `test_hop_scaling` | Hop scaling algorithm |
|
||||
| `test_http_content_handler` | HTTP handling |
|
||||
| `test_mac_from_string` | MAC address parsing |
|
||||
| `test_mesh_module` | Module framework |
|
||||
| `test_meshpacket_serializer` | Packet serialization |
|
||||
| `test_transmit_history` | Retransmission tracking |
|
||||
| `test_atak` | ATAK integration |
|
||||
| `test_default` | Default configuration helpers |
|
||||
| `test_http_content_handler` | HTTP handling |
|
||||
| `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_hop_scaling` | Hop scaling algorithm |
|
||||
| `test_traffic_management` | Traffic management |
|
||||
| `test_transmit_history` | Retransmission tracking |
|
||||
| `test_type_conversions` | NodeDB v25 type conversions |
|
||||
| `test_utf8` | UTF-8 utilities |
|
||||
|
||||
@@ -36,7 +36,6 @@ static MockMeshService *mockMeshService;
|
||||
// -----------------------------------------------------------------------
|
||||
// getRegion() tests
|
||||
// -----------------------------------------------------------------------
|
||||
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
|
||||
|
||||
static void test_getRegion_returnsCorrectRegion_US()
|
||||
{
|
||||
@@ -104,6 +103,29 @@ static void test_validateConfigRegion_unsetRegionReturnsTrue()
|
||||
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
|
||||
}
|
||||
|
||||
static void test_validateConfigRegion_unknownCodeReturnsFalse()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)255;
|
||||
|
||||
devicestate.owner.is_licensed = false;
|
||||
|
||||
// Unknown code is not in the regions table; getRegion() returns the UNSET sentinel,
|
||||
// whose .code != 255, so validateConfigRegion should reject it.
|
||||
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
|
||||
}
|
||||
|
||||
static void test_validateConfigRegion_anotherUnknownCodeReturnsFalse()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)99;
|
||||
|
||||
devicestate.owner.is_licensed = true;
|
||||
|
||||
// Unknown code should be rejected even when owner is licensed.
|
||||
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shadow tables for testing (preset lists → profiles → regions → lookup)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -138,7 +160,6 @@ static const RegionProfile TEST_PROFILE_SPACED = {
|
||||
/* textThrottle */ 0,
|
||||
/* positionThrottle */ 0,
|
||||
/* telemetryThrottle */ 0,
|
||||
/* overrideSlot */ 0,
|
||||
};
|
||||
|
||||
// A licensed-only profile for testing access control
|
||||
@@ -151,7 +172,6 @@ static const RegionProfile TEST_PROFILE_LICENSED = {
|
||||
/* textThrottle */ 5,
|
||||
/* positionThrottle */ 10,
|
||||
/* telemetryThrottle */ 10,
|
||||
/* overrideSlot */ 3,
|
||||
};
|
||||
|
||||
// Turbo-only profile
|
||||
@@ -164,7 +184,6 @@ static const RegionProfile TEST_PROFILE_TURBO = {
|
||||
/* textThrottle */ 0,
|
||||
/* positionThrottle */ 0,
|
||||
/* telemetryThrottle */ 0,
|
||||
/* overrideSlot */ 0,
|
||||
};
|
||||
|
||||
// A preset list for the preset-hash override slot test (LONG_FAST + MEDIUM_FAST)
|
||||
@@ -185,7 +204,6 @@ static const RegionProfile TEST_PROFILE_PRESET_HASH = {
|
||||
/* textThrottle */ 0,
|
||||
/* positionThrottle */ 0,
|
||||
/* telemetryThrottle */ 0,
|
||||
/* overrideSlot */ OVERRIDE_SLOT_PRESET_HASH,
|
||||
};
|
||||
|
||||
// Standalone test region using US frequencies (26 MHz span → 104 slots at 250 kHz BW)
|
||||
@@ -200,25 +218,26 @@ static const RegionInfo TEST_REGION_PRESET_HASH = {
|
||||
false,
|
||||
&TEST_PROFILE_PRESET_HASH,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
|
||||
OVERRIDE_SLOT_PRESET_HASH,
|
||||
"TEST_PRESET_HASH",
|
||||
};
|
||||
|
||||
static const RegionInfo testRegions[] = {
|
||||
// A wide US-like region with spacing + padding
|
||||
{meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, "TEST_US_SPACED"},
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, "TEST_US_SPACED"},
|
||||
|
||||
// A narrow band simulating tight EU regulation
|
||||
{meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, "TEST_EU_LICENSED"},
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 3, "TEST_EU_LICENSED"},
|
||||
|
||||
// A wide-LoRa region with turbo-only presets
|
||||
{meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, "TEST_LORA24_TURBO"},
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 0, "TEST_LORA24_TURBO"},
|
||||
|
||||
// Sentinel — must be last
|
||||
{meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, "TEST_UNSET"},
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, "TEST_UNSET"},
|
||||
};
|
||||
|
||||
static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code)
|
||||
@@ -256,7 +275,7 @@ static void test_shadowTable_licensedProfileFlagsCorrect()
|
||||
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
||||
TEST_ASSERT_TRUE(r->profile->licensedOnly);
|
||||
TEST_ASSERT_FALSE(r->profile->audioPermitted);
|
||||
TEST_ASSERT_EQUAL(3, r->profile->overrideSlot);
|
||||
TEST_ASSERT_EQUAL(3, r->overrideSlot);
|
||||
}
|
||||
|
||||
static void test_shadowTable_presetCountMatchesExpected()
|
||||
@@ -319,8 +338,8 @@ static void test_shadowTable_unknownCodeFallsToSentinel()
|
||||
|
||||
static void test_shadowTable_presetHashProfileHasCorrectOverrideSlot()
|
||||
{
|
||||
TEST_ASSERT_EQUAL(OVERRIDE_SLOT_PRESET_HASH, TEST_PROFILE_PRESET_HASH.overrideSlot);
|
||||
TEST_ASSERT_EQUAL(-1, TEST_PROFILE_PRESET_HASH.overrideSlot);
|
||||
TEST_ASSERT_EQUAL(OVERRIDE_SLOT_PRESET_HASH, TEST_REGION_PRESET_HASH.overrideSlot);
|
||||
TEST_ASSERT_EQUAL(-1, TEST_REGION_PRESET_HASH.overrideSlot);
|
||||
TEST_ASSERT_EQUAL(2, TEST_REGION_PRESET_HASH.getNumPresets());
|
||||
}
|
||||
|
||||
@@ -940,6 +959,8 @@ void setup()
|
||||
// validateConfigRegion()
|
||||
RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue);
|
||||
RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue);
|
||||
RUN_TEST(test_validateConfigRegion_unknownCodeReturnsFalse);
|
||||
RUN_TEST(test_validateConfigRegion_anotherUnknownCodeReturnsFalse);
|
||||
|
||||
// Shadow table tests
|
||||
RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing);
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
#include "MeshTypes.h"
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
|
||||
#include "FSCommon.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "modules/HopScalingModule.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
// Unity only shows TEST_MESSAGE output. printf goes to stdout which the runner swallows.
|
||||
#define MSG_BUF_LEN 200
|
||||
#define TEST_MSG_FMT(fmt, ...) \
|
||||
do { \
|
||||
char _buf[MSG_BUF_LEN]; \
|
||||
snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
|
||||
TEST_MESSAGE(_buf); \
|
||||
} while (0)
|
||||
|
||||
static constexpr NodeNum kLocalNode = 0x11111111;
|
||||
|
||||
// Shared mock clock — drives HopScalingModule::nowMs()
|
||||
static uint32_t &mockTime = HopScalingModule::s_testNowMs;
|
||||
static constexpr uint32_t ONE_HOUR_MS = 3600UL * 1000UL;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockNodeDB — not used for hop decisions any more, kept for completeness
|
||||
// ---------------------------------------------------------------------------
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops, uint32_t ageSecs, bool viaMqtt = false)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
node.has_hops_away = hasHops;
|
||||
node.hops_away = hopsAway;
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
|
||||
node.last_heard = getTime() - ageSecs;
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test shim — expose protected/private members for direct invocation
|
||||
// ---------------------------------------------------------------------------
|
||||
class HopScalingTestShim : public HopScalingModule
|
||||
{
|
||||
public:
|
||||
using HopScalingModule::runOnce;
|
||||
using HopScalingModule::samplePacketForHistogram;
|
||||
|
||||
using HopScalingModule::getLastRequiredHop;
|
||||
|
||||
// Test-only helpers (require UNIT_TEST friend access)
|
||||
void rollHourTest() { rollHour(); }
|
||||
void setHistogramDenominator(uint8_t d) { setSamplingDenominator(d); }
|
||||
|
||||
/// Directly set denominator state, bypassing any scale-up/down logic.
|
||||
/// Used by tests that need a specific pre-condition without triggering trim.
|
||||
void forceFilterDenomState(uint8_t samp, uint8_t filt, uint8_t holdRolls)
|
||||
{
|
||||
samplingDenominator = samp;
|
||||
filteringDenominator = filt;
|
||||
filteringDenomHoldRollsRemaining = holdRolls;
|
||||
}
|
||||
uint8_t getFilteringDenomHoldRollsRemaining() const { return filteringDenomHoldRollsRemaining; }
|
||||
|
||||
/// Insert an entry with an explicit hash, bypassing the sampling filter.
|
||||
/// Used to fill the histogram to a known state without depending on hashNodeId distribution.
|
||||
void forceInsertEntry(uint16_t hash, uint8_t hops)
|
||||
{
|
||||
if (count < CAPACITY) {
|
||||
entries[count].nodeHash = hash;
|
||||
entries[count].hops_away = hops;
|
||||
entries[count].seenHoursAgo = 1u;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Size introspection for test_memory_layout
|
||||
static constexpr size_t sizeofSelf() { return sizeof(HopScalingModule); }
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
|
||||
// Create deterministic IDs that produce a broad spread of 16-bit hashes.
|
||||
// HopScalingModule admission uses passesFilter(hashNodeId(nodeId), denom), NOT a raw nodeId
|
||||
// modulo check — do not assume (nodeId & (denom-1)) == 0 determines whether a node is admitted.
|
||||
static uint32_t makeDistributedNodeId(uint32_t baseId, uint32_t ordinal, uint32_t salt = 0)
|
||||
{
|
||||
return baseId + salt + (ordinal * 33u);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — mesh topology builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Helper: add N nodes at a given hop with ages spread across a time range.
|
||||
static void addNodesAtHop(uint32_t baseId, uint8_t hop, uint32_t count, uint32_t ageSecs, uint32_t stride = 10)
|
||||
{
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
const uint32_t nodeId = makeDistributedNodeId(baseId, i, static_cast<uint32_t>(hop) << 8);
|
||||
mockNodeDB->addTestNode(nodeId, hop, true, ageSecs + i * stride);
|
||||
}
|
||||
}
|
||||
|
||||
// Feed sampled traffic into the histogram.
|
||||
// Advances mock clock by one hour per roll and calls rollHour() so each roll produces data.
|
||||
static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const uint16_t hopDist[HOP_MAX + 1],
|
||||
uint8_t numRolls = 16)
|
||||
{
|
||||
shim.setHistogramDenominator(HopScalingModule::DENOM_MIN);
|
||||
|
||||
for (uint8_t roll = 0; roll < numRolls; ++roll) {
|
||||
mockTime += ONE_HOUR_MS;
|
||||
|
||||
uint16_t ordinal = 0;
|
||||
for (uint8_t hop = 0; hop <= HOP_MAX; ++hop) {
|
||||
for (uint16_t n = 0; n < hopDist[hop]; ++n) {
|
||||
const uint32_t nodeId = makeDistributedNodeId(baseId, ordinal);
|
||||
shim.samplePacketForHistogram(nodeId, hop);
|
||||
++ordinal;
|
||||
}
|
||||
}
|
||||
shim.rollHourTest();
|
||||
}
|
||||
}
|
||||
|
||||
static void assertCompactHistogramActive(HopScalingTestShim &shim)
|
||||
{
|
||||
TEST_ASSERT_GREATER_THAN_UINT8(0, shim.getCompactHistogramEntryCount());
|
||||
TEST_ASSERT_TRUE(shim.getCompactHistogramAllSampleCount() > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topology builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Scenario A: Dense local mesh — 110 nodes, heavy at hops 0–2.
|
||||
static void buildDenseLocalMesh()
|
||||
{
|
||||
mockNodeDB->clearTestNodes();
|
||||
addNodesAtHop(0x1000, 0, 25, 120);
|
||||
addNodesAtHop(0x2000, 1, 30, 300);
|
||||
addNodesAtHop(0x3000, 2, 15, 600);
|
||||
addNodesAtHop(0x4000, 3, 5, 1200);
|
||||
addNodesAtHop(0x5000, 4, 10, 1800);
|
||||
addNodesAtHop(0x6000, 5, 15, 2400);
|
||||
addNodesAtHop(0x7000, 6, 10, 3000);
|
||||
}
|
||||
|
||||
// Scenario B: Spread sparse mesh — 76 nodes across hops 0–7.
|
||||
static void buildSpreadSparseMesh()
|
||||
{
|
||||
mockNodeDB->clearTestNodes();
|
||||
addNodesAtHop(0x1000, 0, 5, 120);
|
||||
addNodesAtHop(0x2000, 1, 8, 300);
|
||||
addNodesAtHop(0x3000, 2, 12, 600);
|
||||
addNodesAtHop(0x4000, 3, 15, 900);
|
||||
addNodesAtHop(0x5000, 4, 10, 1200);
|
||||
addNodesAtHop(0x6000, 5, 6, 1800);
|
||||
addNodesAtHop(0x7000, 6, 10, 3000);
|
||||
addNodesAtHop(0x8000, 7, 10, 3600);
|
||||
}
|
||||
|
||||
// Scenario C: Deep linear chain — 22 thin nodes, never reaches 40.
|
||||
static void buildDeepLinearChain()
|
||||
{
|
||||
mockNodeDB->clearTestNodes();
|
||||
addNodesAtHop(0x1000, 0, 2, 120);
|
||||
addNodesAtHop(0x2000, 1, 3, 300);
|
||||
addNodesAtHop(0x3000, 2, 3, 600);
|
||||
addNodesAtHop(0x4000, 3, 4, 900);
|
||||
addNodesAtHop(0x5000, 4, 3, 1200);
|
||||
addNodesAtHop(0x6000, 5, 2, 1800);
|
||||
addNodesAtHop(0x7000, 6, 2, 2400);
|
||||
addNodesAtHop(0x8000, 7, 3, 3600);
|
||||
}
|
||||
|
||||
// Scenario D: Router cluster — 71 nodes, 45 at hop 2.
|
||||
static void buildRouterCluster()
|
||||
{
|
||||
mockNodeDB->clearTestNodes();
|
||||
addNodesAtHop(0x1000, 0, 3, 120);
|
||||
addNodesAtHop(0x2000, 1, 5, 300);
|
||||
addNodesAtHop(0x3000, 2, 45, 600);
|
||||
addNodesAtHop(0x4000, 3, 8, 1200);
|
||||
addNodesAtHop(0x5000, 4, 3, 1200);
|
||||
addNodesAtHop(0x6000, 5, 2, 1800);
|
||||
addNodesAtHop(0x7000, 6, 2, 2400);
|
||||
addNodesAtHop(0x8000, 7, 3, 3600);
|
||||
}
|
||||
|
||||
// Scenario E: Megamesh — 199 nodes (DB near capacity).
|
||||
static void buildMegamesh()
|
||||
{
|
||||
mockNodeDB->clearTestNodes();
|
||||
addNodesAtHop(0x01000, 0, 30, 120);
|
||||
addNodesAtHop(0x02000, 1, 40, 300);
|
||||
addNodesAtHop(0x03000, 2, 35, 600);
|
||||
addNodesAtHop(0x04000, 3, 30, 900);
|
||||
addNodesAtHop(0x05000, 4, 20, 1200);
|
||||
addNodesAtHop(0x06000, 5, 15, 1800);
|
||||
addNodesAtHop(0x07000, 6, 14, 2400);
|
||||
addNodesAtHop(0x08000, 7, 15, 3600);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — Topology-driven hop reduction scenarios
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void test_dense_local_telemetry()
|
||||
{
|
||||
TEST_MESSAGE("=== Dense local mesh: telemetry broadcast ===");
|
||||
TEST_MESSAGE("Topology: 110 nodes with 25/30/15 nodes at hops 0/1/2 and a thinner tail to hop 6.");
|
||||
TEST_MESSAGE("Expectation: cumulative reaches 55 nodes by hop 1, result stays tightly constrained.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildDenseLocalMesh();
|
||||
const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
|
||||
injectSampleTraffic(*shim, 0x91000000, distA);
|
||||
shim->runOnce();
|
||||
|
||||
TEST_MSG_FMT("Dense local: hop=%u", shim->getLastRequiredHop());
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3);
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 1);
|
||||
assertCompactHistogramActive(*shim);
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_spread_sparse_position()
|
||||
{
|
||||
TEST_MESSAGE("=== Spread sparse mesh: position broadcast ===");
|
||||
TEST_MESSAGE("Topology: 76 nodes spread across all hops, reaching 40 nodes only when hop 3 is included.");
|
||||
TEST_MESSAGE("Expectation: hop settles in the 3-5 range.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildSpreadSparseMesh();
|
||||
const uint16_t distB[HOP_MAX + 1] = {5, 8, 12, 15, 10, 6, 10, 10};
|
||||
injectSampleTraffic(*shim, 0x92000000, distB);
|
||||
shim->runOnce();
|
||||
|
||||
TEST_MSG_FMT("Spread sparse: hop=%u", shim->getLastRequiredHop());
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 3);
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 5);
|
||||
assertCompactHistogramActive(*shim);
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_deep_chain_position()
|
||||
{
|
||||
TEST_MESSAGE("=== Deep linear chain: position broadcast ===");
|
||||
TEST_MESSAGE("Topology: 22 nodes spread thinly across hops 0-7, never reaching the 40-node floor.");
|
||||
TEST_MESSAGE("Expectation: module must keep HOP_MAX.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildDeepLinearChain();
|
||||
const uint16_t distC[HOP_MAX + 1] = {2, 3, 3, 4, 3, 2, 2, 3};
|
||||
injectSampleTraffic(*shim, 0x93000000, distC);
|
||||
shim->runOnce();
|
||||
|
||||
TEST_MSG_FMT("Deep chain: hop=%u", shim->getLastRequiredHop());
|
||||
TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop());
|
||||
assertCompactHistogramActive(*shim);
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_router_cluster_telemetry()
|
||||
{
|
||||
TEST_MESSAGE("=== Router cluster: telemetry broadcast ===");
|
||||
TEST_MESSAGE("Topology: 71 nodes with a concentrated 45-node cluster at hop 2.");
|
||||
TEST_MESSAGE("Expectation: result stays in the 2-4 range.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildRouterCluster();
|
||||
const uint16_t distD[HOP_MAX + 1] = {3, 5, 45, 8, 3, 2, 2, 3};
|
||||
injectSampleTraffic(*shim, 0x94000000, distD);
|
||||
shim->runOnce();
|
||||
|
||||
TEST_MSG_FMT("Router cluster: hop=%u", shim->getLastRequiredHop());
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 2);
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 4);
|
||||
assertCompactHistogramActive(*shim);
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_megamesh_eviction_scaling()
|
||||
{
|
||||
TEST_MESSAGE("=== Megamesh with eviction scaling ===");
|
||||
TEST_MESSAGE("Topology: NodeDB at capacity (199 nodes), ~2000-node mesh with sustained eviction pressure.");
|
||||
TEST_MESSAGE("Expectation: sustained evictions tracked in rolling average, hop stays well below HOP_MAX.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildMegamesh();
|
||||
|
||||
const uint16_t distE[HOP_MAX + 1] = {301, 402, 352, 301, 201, 151, 141, 151};
|
||||
injectSampleTraffic(*shim, 0x9B000000, distE);
|
||||
|
||||
shim->runOnce();
|
||||
uint8_t hopBefore = shim->getLastRequiredHop();
|
||||
TEST_MSG_FMT("Megamesh initial: hop=%u", hopBefore);
|
||||
|
||||
for (int hour = 0; hour < 3; hour++) {
|
||||
mockTime += ONE_HOUR_MS;
|
||||
{
|
||||
const uint16_t megaDist[HOP_MAX + 1] = {301, 402, 352, 301, 201, 151, 141, 151};
|
||||
uint16_t ordinal = 0;
|
||||
for (uint8_t hop = 0; hop <= HOP_MAX; ++hop) {
|
||||
for (uint16_t n = 0; n < megaDist[hop]; ++n) {
|
||||
const uint32_t nodeId = makeDistributedNodeId(0x9C000000u, ordinal, static_cast<uint32_t>(hour) * 0x10000u);
|
||||
shim->samplePacketForHistogram(nodeId, hop);
|
||||
++ordinal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int run = 0; run < 7; run++)
|
||||
shim->runOnce();
|
||||
|
||||
TEST_MSG_FMT("Megamesh hour %d: hop=%u", hour + 1, shim->getLastRequiredHop());
|
||||
}
|
||||
|
||||
TEST_MESSAGE("Assertion: hop stays well below HOP_MAX on a large-distribution mesh.");
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3);
|
||||
assertCompactHistogramActive(*shim);
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_sparse_to_dense_transition()
|
||||
{
|
||||
TEST_MESSAGE("=== Sparse-to-dense transition ===");
|
||||
TEST_MESSAGE("Topology change: start with a 22-node deep chain, then inject 50 new neighbors at hops 0-1.");
|
||||
TEST_MESSAGE("Expectation: hop drops sharply once the local neighborhood becomes dense.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
|
||||
buildDeepLinearChain();
|
||||
const uint16_t distC2[HOP_MAX + 1] = {2, 3, 3, 4, 3, 2, 2, 3};
|
||||
injectSampleTraffic(*shim, 0x95000000, distC2);
|
||||
shim->runOnce();
|
||||
uint8_t hopSparse = shim->getLastRequiredHop();
|
||||
TEST_MSG_FMT("Phase 1 sparse: hop=%u (expect %u)", hopSparse, HOP_MAX);
|
||||
TEST_ASSERT_EQUAL_UINT8(HOP_MAX, hopSparse);
|
||||
|
||||
addNodesAtHop(0xA000, 0, 25, 120);
|
||||
addNodesAtHop(0xB000, 1, 25, 300);
|
||||
|
||||
for (uint32_t i = 0; i < 25; ++i)
|
||||
shim->samplePacketForHistogram(makeDistributedNodeId(0xA000, i, static_cast<uint32_t>(0) << 8), 0);
|
||||
for (uint32_t i = 0; i < 25; ++i)
|
||||
shim->samplePacketForHistogram(makeDistributedNodeId(0xB000, i, static_cast<uint32_t>(1) << 8), 1);
|
||||
|
||||
for (int run = 0; run < HopScalingModule::RUNS_PER_HOUR; run++)
|
||||
shim->runOnce();
|
||||
|
||||
uint8_t hopDense = shim->getLastRequiredHop();
|
||||
TEST_MSG_FMT("Phase 2 dense: hop=%u (expect <= 3)", hopDense);
|
||||
|
||||
TEST_ASSERT_TRUE(hopDense < hopSparse);
|
||||
TEST_ASSERT_TRUE(hopDense <= 3);
|
||||
assertCompactHistogramActive(*shim);
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_state_persistence()
|
||||
{
|
||||
TEST_MESSAGE("=== State persistence across restart ===");
|
||||
TEST_MESSAGE("Expectation: histogram entries survive instance teardown and reload.");
|
||||
|
||||
{
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
|
||||
const uint16_t dist[HOP_MAX + 1] = {5, 8, 12, 10, 5, 3, 2, 1};
|
||||
injectSampleTraffic(*shim, 0x9D000000, dist, 2);
|
||||
|
||||
TEST_MSG_FMT("Phase 1: entries=%u hop=%u", shim->getEntryCount(), shim->getLastRequiredHop());
|
||||
TEST_ASSERT_GREATER_THAN_UINT8(0, shim->getEntryCount());
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
{
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
shim->runOnce();
|
||||
|
||||
TEST_MSG_FMT("Phase 2 restored: entries=%u hop=%u", shim->getEntryCount(), shim->getLastRequiredHop());
|
||||
TEST_ASSERT_GREATER_THAN_UINT8(0, shim->getEntryCount());
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void test_hourly_roll()
|
||||
{
|
||||
TEST_MESSAGE("=== Hourly roll cycle ===");
|
||||
TEST_MESSAGE("Expectation: histogram accumulates data and provides valid hop recommendation after multiple rolls.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildSpreadSparseMesh();
|
||||
shim->setHistogramDenominator(HopScalingModule::DENOM_MIN);
|
||||
|
||||
for (uint32_t i = 1; i <= 30; i++) {
|
||||
const uint32_t nodeId = makeDistributedNodeId(0x97000000, i, 0xAAu);
|
||||
shim->samplePacketForHistogram(nodeId, static_cast<uint8_t>(i % (HOP_MAX + 1)));
|
||||
}
|
||||
|
||||
for (int run = 0; run < 13; run++) {
|
||||
int32_t interval = shim->runOnce();
|
||||
TEST_ASSERT_GREATER_THAN(0, interval);
|
||||
}
|
||||
|
||||
TEST_MSG_FMT("Hourly roll: hop=%u entries=%u", shim->getLastRequiredHop(), shim->getEntryCount());
|
||||
assertCompactHistogramActive(*shim);
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_intermediate_status()
|
||||
{
|
||||
TEST_MESSAGE("=== Intermediate status (no recomputation) ===");
|
||||
TEST_MESSAGE("Expectation: runs between hourly updates leave hop unchanged.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildRouterCluster();
|
||||
const uint16_t distD[HOP_MAX + 1] = {3, 5, 45, 8, 3, 2, 2, 3};
|
||||
injectSampleTraffic(*shim, 0x98000000, distD);
|
||||
|
||||
shim->runOnce();
|
||||
uint8_t hopAfterInitial = shim->getLastRequiredHop();
|
||||
TEST_MSG_FMT("Initial: hop=%u", hopAfterInitial);
|
||||
|
||||
for (int run = 0; run < 3; run++) {
|
||||
shim->runOnce();
|
||||
TEST_ASSERT_EQUAL_UINT8(hopAfterInitial, shim->getLastRequiredHop());
|
||||
}
|
||||
|
||||
TEST_MSG_FMT("After 3 intermediate runs: hop=%u (unchanged)", shim->getLastRequiredHop());
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_startup_blank_state()
|
||||
{
|
||||
TEST_MESSAGE("=== Startup with blank state ===");
|
||||
TEST_MESSAGE("Expectation: fresh instance starts with zeroed rolling averages and a valid hop result.");
|
||||
|
||||
#ifdef FSCom
|
||||
FSCom.remove("/prefs/hopScalingState.bin");
|
||||
#endif
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildDeepLinearChain();
|
||||
|
||||
int32_t interval = shim->runOnce();
|
||||
|
||||
TEST_ASSERT_GREATER_THAN(0, interval);
|
||||
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= HOP_MAX);
|
||||
|
||||
TEST_MSG_FMT("Startup blank: hop=%u", shim->getLastRequiredHop());
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — Denominator state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void test_denominator_rises_on_overflow()
|
||||
{
|
||||
TEST_MESSAGE("=== samplingDenominator doubles when histogram overflows ===");
|
||||
TEST_MESSAGE("Fill to > FILL_HIGH_PCT with forceInsertEntry, then trigger via samplePacketForHistogram.");
|
||||
TEST_MESSAGE("Expectation: samp/filt both double to 2, hold set to FILTER_DENOM_HOLD_ROLLS.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
|
||||
// Insert 103 entries with hashes 1..103 (all distinct, no sampling-filter skew).
|
||||
// 103 / 128 = 80.4% fill, which meets FILL_HIGH_PCT=80.
|
||||
// Odd hashes (1,3,...,103) will be evicted when denom doubles to 2; even ones survive.
|
||||
static constexpr uint8_t FILL_COUNT = 103u;
|
||||
for (uint8_t i = 1; i <= FILL_COUNT; i++)
|
||||
shim->forceInsertEntry(i, 2u);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getSamplingDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getFilteringDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(0u, shim->getFilteringDenomHoldRollsRemaining());
|
||||
TEST_ASSERT_EQUAL_UINT8(FILL_COUNT, shim->getEntryCount());
|
||||
|
||||
// A new node passes the denom=1 admission gate; fill ≥ 80% triggers trimIfNeeded → doubling.
|
||||
shim->samplePacketForHistogram(0xB0000000u, 1u);
|
||||
|
||||
TEST_MSG_FMT("After scale-up: samp=1/%u filt=1/%u holdRolls=%u entries=%u", shim->getSamplingDenominator(),
|
||||
shim->getFilteringDenominator(), shim->getFilteringDenomHoldRollsRemaining(), shim->getEntryCount());
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT8(2u, shim->getSamplingDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::FILTER_DENOM_HOLD_ROLLS, shim->getFilteringDenomHoldRollsRemaining());
|
||||
// After evicting entries with (hash & 1) != 0, roughly half the entries remain.
|
||||
TEST_ASSERT_LESS_THAN_UINT8(FILL_COUNT, shim->getEntryCount());
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_filtering_denom_hold_counts_down()
|
||||
{
|
||||
TEST_MESSAGE("=== filteringDenominator held while hold counter > 0 ===");
|
||||
TEST_MESSAGE("Force filt=4 samp=1 hold=3; verify no step for 2 rolls, then step fires on roll 3.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
|
||||
// samp=DENOM_MIN so scale-down in step 4 can't go lower; hold=3 for a short, fast test.
|
||||
shim->forceFilterDenomState(HopScalingModule::DENOM_MIN, 4u, 3u);
|
||||
|
||||
shim->rollHourTest(); // hold 3→2, no step
|
||||
TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenomHoldRollsRemaining());
|
||||
|
||||
shim->rollHourTest(); // hold 2→1, no step
|
||||
TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenomHoldRollsRemaining());
|
||||
|
||||
// Roll 3: hold 1→0, step fires — filteringDenominator halves to max(2, samp=1) = 2.
|
||||
shim->rollHourTest();
|
||||
TEST_MSG_FMT("After hold expires: filt=1/%u samp=1/%u holdRolls=%u", shim->getFilteringDenominator(),
|
||||
shim->getSamplingDenominator(), shim->getFilteringDenomHoldRollsRemaining());
|
||||
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(0u, shim->getFilteringDenomHoldRollsRemaining());
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_filtering_denom_steps_down_gradually()
|
||||
{
|
||||
TEST_MESSAGE("=== filteringDenominator descends one halving per rollHour() after hold expires ===");
|
||||
TEST_MESSAGE("Force filt=8 samp=1 hold=1; expect 8→4→2→1 over 3 rolls, then stable.");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
|
||||
shim->forceFilterDenomState(HopScalingModule::DENOM_MIN, 8u, 1u);
|
||||
|
||||
shim->rollHourTest(); // hold 1→0, step: 8/2=4 > 1, filt=4
|
||||
TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
|
||||
|
||||
shim->rollHourTest(); // hold=0 (no decrement), step: 4/2=2 > 1, filt=2
|
||||
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
|
||||
|
||||
shim->rollHourTest(); // step: 2/2=1, not > samp=1, filt=samp=1 — converged
|
||||
TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenominator());
|
||||
|
||||
shim->rollHourTest(); // filt==samp, outer if is false — no further change
|
||||
TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenominator());
|
||||
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getSamplingDenominator());
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_full_at_denom_max_drops_entry()
|
||||
{
|
||||
TEST_MESSAGE("=== Full histogram at DENOM_MAX drops new entries ===");
|
||||
TEST_MESSAGE("Fill CAPACITY entries, force samp=DENOM_MAX, sample admissible node.");
|
||||
TEST_MESSAGE("Expectation: entry count stays at CAPACITY (LOG_WARN fires; visible in test output).");
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
shim->setHashSeed(0); // deterministic hash for admissible-ID search
|
||||
|
||||
shim->forceFilterDenomState(HopScalingModule::DENOM_MAX, HopScalingModule::DENOM_MAX, 0u);
|
||||
|
||||
// Fill with odd hashes 1,3,5,...,(2*CAPACITY-1). None are multiples of 128, so none
|
||||
// collide with the admissible node's hash (which must be a multiple of 128).
|
||||
for (uint16_t i = 0; i < HopScalingModule::CAPACITY; i++)
|
||||
shim->forceInsertEntry(static_cast<uint16_t>(2u * i + 1u), 1u);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::CAPACITY, shim->getEntryCount());
|
||||
|
||||
// Find a node ID whose hash passes DENOM_MAX, i.e. (hash & 127) == 0.
|
||||
uint32_t admissibleId = 0;
|
||||
for (uint32_t id = 1u; id < 0x10000u; id++) {
|
||||
if ((shim->hashNodeIdPublic(id) & (HopScalingModule::DENOM_MAX - 1u)) == 0u) {
|
||||
admissibleId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
TEST_ASSERT_NOT_EQUAL(0u, admissibleId); // sanity: the hash space is dense enough to find one quickly
|
||||
|
||||
shim->samplePacketForHistogram(admissibleId, 3u);
|
||||
|
||||
TEST_MSG_FMT("After drop attempt: entries=%u CAPACITY=%u admissibleId=0x%08x hash=0x%04x", shim->getEntryCount(),
|
||||
static_cast<unsigned>(HopScalingModule::CAPACITY), admissibleId,
|
||||
static_cast<unsigned>(shim->hashNodeIdPublic(admissibleId)));
|
||||
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::CAPACITY, shim->getEntryCount());
|
||||
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void test_scenario_summary_output()
|
||||
{
|
||||
TEST_MESSAGE("=== Scenario summary ===");
|
||||
TEST_MESSAGE("Scenario | Nodes | Distribution | Hop | Why");
|
||||
TEST_MESSAGE("A: Dense local | 110 | 25/30/15/5/10/15/10 h0-6 | 1-2 | 55 nodes at h1 >> 40");
|
||||
TEST_MESSAGE("B: Spread | 76 | 5/8/12/15/10/6/10/10 h0-7 | 3-4 | Need h3 to reach 40");
|
||||
TEST_MESSAGE("C: Deep chain | 22 | 2/3/3/4/3/2/2/3 h0-7 | 7 | Never reaches 40");
|
||||
TEST_MESSAGE("D: Router | 71 | 3/5/45/8/3/2/2/3 h0-7 | 2-3 | 45-node hop-2 cluster");
|
||||
TEST_MESSAGE("E: Megamesh | 199 | 30/40/35/30/20/15/14/15 h0-7 | 0-1 | Dense low-hop histogram");
|
||||
TEST_MESSAGE("F: Transition | 22->72 | Chain -> dense local | 7-><=3 | Adapts to new neighbors");
|
||||
TEST_MESSAGE("G: Persistence | -- | -- | -- | Eviction avg survives reboot");
|
||||
TEST_MESSAGE("");
|
||||
TEST_MESSAGE("=== Denominator state machine summary ===");
|
||||
TEST_MESSAGE("Test | Pre-condition | Expectation");
|
||||
TEST_MESSAGE("H: Rises on overflow | 103 entries forced, denom=1 | samp/filt→2, holdRolls=13");
|
||||
TEST_MESSAGE(
|
||||
"I: Hold counts down | filt=4 samp=1 hold=3 | no step for 2 rolls, step on roll 3: filt→2");
|
||||
TEST_MESSAGE("J: Steps down gradually | filt=8 samp=1 hold=1 | 8→4→2→1 over 3 rolls, stable on 4th");
|
||||
TEST_MESSAGE("K: Full at DENOM_MAX drops entry | 128 entries, samp=filt=128 | count stays 128, LOG_WARN emitted");
|
||||
}
|
||||
|
||||
static void test_memory_layout()
|
||||
{
|
||||
TEST_MSG_FMT("%-35s %6s %s", "Type", "bytes", "Notes");
|
||||
TEST_MSG_FMT("%-35s %6zu %s", "Record", sizeof(Record), "nodeHash:16 + hops:3 + seen:13 (32-bit packed)");
|
||||
TEST_MSG_FMT("%-35s %6zu %s", "HopScalingModule::PerHopCounts", sizeof(HopScalingModule::PerHopCounts),
|
||||
"perHop[8](16) + total(2)");
|
||||
TEST_MSG_FMT("%-35s %6zu %s", "HopScalingModule (instance)", HopScalingTestShim::sizeofSelf(),
|
||||
"entries[128](512) + denom state + cached results + OSThread overhead");
|
||||
|
||||
TEST_PASS();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unity setup / teardown / main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
if (!mockNodeDB)
|
||||
mockNodeDB = new MockNodeDB();
|
||||
mockNodeDB->clearTestNodes();
|
||||
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
|
||||
myNodeInfo.my_node_num = kLocalNode;
|
||||
nodeDB = mockNodeDB;
|
||||
|
||||
#ifdef FSCom
|
||||
FSCom.remove("/prefs/hopScalingState.bin");
|
||||
#endif
|
||||
|
||||
// Reset mock clock to a known base (1 hour in so subtraction never underflows)
|
||||
mockTime = ONE_HOUR_MS;
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
hopScalingModule = nullptr;
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
nodeDB = mockNodeDB;
|
||||
|
||||
UNITY_BEGIN();
|
||||
|
||||
printf("\n=== Topology-driven hop reduction ===\n");
|
||||
RUN_TEST(test_dense_local_telemetry);
|
||||
RUN_TEST(test_spread_sparse_position);
|
||||
RUN_TEST(test_deep_chain_position);
|
||||
RUN_TEST(test_router_cluster_telemetry);
|
||||
RUN_TEST(test_megamesh_eviction_scaling);
|
||||
RUN_TEST(test_sparse_to_dense_transition);
|
||||
|
||||
printf("\n=== Lifecycle ===\n");
|
||||
RUN_TEST(test_state_persistence);
|
||||
RUN_TEST(test_hourly_roll);
|
||||
RUN_TEST(test_intermediate_status);
|
||||
RUN_TEST(test_startup_blank_state);
|
||||
|
||||
printf("\n=== Denominator state machine ===\n");
|
||||
RUN_TEST(test_denominator_rises_on_overflow);
|
||||
RUN_TEST(test_filtering_denom_hold_counts_down);
|
||||
RUN_TEST(test_filtering_denom_steps_down_gradually);
|
||||
RUN_TEST(test_full_at_denom_max_drops_entry);
|
||||
|
||||
printf("\n=== Summary ===\n");
|
||||
RUN_TEST(test_memory_layout);
|
||||
RUN_TEST(test_scenario_summary_output);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
||||
#else // !HAS_VARIABLE_HOPS
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user