Compare commits

..
364 changed files with 5366 additions and 10977 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "f=$(tr -d '\\n' | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed 's/.*:[[:space:]]*\"//; s/\"$//'); [ -n \"$f\" ] && [ -f \"$f\" ] || exit 0; t=$(command -v trunk || echo \"$HOME/.cache/trunk/launcher/trunk\"); [ -x \"$t\" ] || { echo \"trunk-fmt hook: trunk not found; its launcher needs curl or wget to bootstrap the CLI (see 'Formatting & the trunk toolchain' in .github/copilot-instructions.md)\" >&2; exit 1; }; out=$(\"$t\" fmt --force \"$f\" 2>&1) || { echo \"trunk-fmt hook: trunk fmt failed on $f: $out\" >&2; exit 1; }",
"timeout": 120,
"statusMessage": "Formatting (trunk)..."
}
]
}
]
}
}
+3 -12
View File
@@ -283,15 +283,6 @@ firmware/
## Coding Conventions
### Formatting & the trunk toolchain
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
### General Style
- Follow existing code style - run `trunk fmt` before commits
@@ -385,14 +376,14 @@ Multiple display driver families in `src/graphics/`:
- **OLED**: SSD1306, SH1106, ST7567
- **TFT**: TFTDisplay (LovyanGFX-based)
- **E-Ink**: EInkDisplay2, EInkDynamicDisplay, EInkParallelDisplay
- **E-Ink**: `src/graphics/BaseUIEInkDisplay.*` is the OLEDDisplay-compatible adapter (peer of `TFTDisplay`). The hardware layer it drives lives in `src/graphics/eink/` — chipset drivers in `Drivers/`, panel profiles in `Panels/`, optional `Backlight/`. Shared by both BaseUI-on-eink and InkHUD builds.
**InkHUD** (`src/graphics/niche/InkHUD/`) is an event-driven e-ink UI framework:
**InkHUD** (`src/graphics/niche/`) is an event-driven e-ink UI framework that sits on top of the `graphics/eink/` layer:
- Applet-based architecture — modular display tiles
- Read-only, static display optimized for minimal refreshes and low power
- Configured per-variant via `nicheGraphics.h`
- Separate PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
- Build helpers in top-level `platformio.ini``[niche]` pulls `graphics/eink/` only (BaseUI path), `[inkhud]` extends it with `graphics/niche/`
### Input System
+1 -1
View File
@@ -132,7 +132,7 @@ For e-ink display variants using the InkHUD framework, add `nicheGraphics.h`:
// Configure display, applets, and refresh behavior per device
```
InkHUD has its own PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
InkHUD and the shared E-Ink layer are wired up via the top-level `platformio.ini` `[niche]` (BaseUI + driver/panel layer in `src/graphics/eink/`) and `[inkhud]` (adds the InkHUD UI in `src/graphics/niche/`). Variants opt in with `extends = ..., niche` or `extends = ..., inkhud`.
## I2C Device Detection
-187
View File
@@ -1,187 +0,0 @@
name: Post Web Flasher Link Comment
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
pull-requests: write
actions: read
jobs:
post-flasher-link:
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'cancelled' &&
github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
# Per-board manifests carry the firmware's own metadata (activelySupported,
# displayName, ...) generated from each target's custom_meshtastic_* config.
- name: Download board manifests
uses: actions/download-artifact@v8
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: manifest-*
path: ./manifests
merge-multiple: true
- name: Post or update web flasher link comment
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const run = context.payload.workflow_run;
const { owner, repo } = context.repo;
// Resolve the PR by matching the run's head SHA against the repo's open
// PRs. workflow_run.pull_requests is empty for fork PRs, and
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
// its head commit — but pulls.list includes fork PRs. Matching on head
// SHA also enforces that the run is for the PR's current commit, so stale
// re-runs of an outdated commit won't match.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
const pr = openPrs.find((p) => p.head.sha === run.head_sha);
if (!pr) {
core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
return;
}
const prNumber = pr.number;
// Restrict to trusted authors. NOTE: author_association is computed for
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
// alone silently excludes most maintainers. We allow the trusted set the
// token can actually identify (members, collaborators, and anyone with a
// previously merged PR). For strict members-only you'd need an org-read
// App/PAT token to call orgs.checkMembershipForUser.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Require at least one per-arch firmware artifact from gather-artifacts
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner, repo, run_id: run.id, per_page: 100,
});
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
if (archArtifacts.length === 0) {
core.info('No per-arch firmware artifacts found; skipping.');
return;
}
const version = archRe.exec(archArtifacts[0].name)[2];
const expiresAt = archArtifacts[0].expires_at
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
: null;
// Read each built board's manifest (.mt.json). activelySupported,
// displayName and architecture come straight from the board's
// custom_meshtastic_* platformio config, so the list is in sync with
// the firmware itself — no external device database needed.
const fs = require('fs');
let boards = [];
try {
boards = fs.readdirSync('./manifests')
.filter((f) => f.endsWith('.mt.json'))
.map((f) => {
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
catch { return null; }
})
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
.map((m) => ({
board: m.platformioTarget,
platform: m.architecture || '',
// displayName is maintainer-authored text; escape table-breaking pipes
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
}))
.sort((a, b) => a.board.localeCompare(b.board));
} catch (e) {
core.warning(`Could not read board manifests: ${e.message}`);
}
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
// Device illustrations are served by the flasher from the same image
// names the manifest declares (custom_meshtastic_images). The flasher
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
// image really resolves to an image before linking it.
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
await Promise.all(boards.map(async (b) => {
if (!b.image) return;
try {
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
const type = res.headers.get('content-type') || '';
if (!res.ok || !type.startsWith('image/')) b.image = '';
} catch { b.image = ''; }
}));
const boardLines = boards
.map((b) => {
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
})
.join('\n');
// Shields.io badges. Only non-user-controlled, charset-constrained values
// (version, commit sha, counts, dates) go into badge URLs — never board
// names or the PR title — so the rendered comment cannot be spoofed.
const shieldText = (s) =>
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
const shield = (label, message, color) =>
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
const buttonUrl =
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
const badges = [
`![firmware](${shield('firmware', version, '67EA94')})`,
`![commit](${shield('commit', run.head_sha.slice(0, 7), '2C2D3C')})`,
`![boards](${shield('boards', boards.length, '5C6BC0')})`,
];
if (expiresAt) badges.push(`![expires](${shield('expires', expiresAt, '9A4E00')})`);
// Only render the board table when there are supported boards to list
const boardTable = boards.length > 0 ? [
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
'',
'| | Device | Board | Platform |',
'| --- | --- | --- | --- |',
boardLines,
'',
'</details>',
'',
] : [];
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
`[![Flash this PR in the Web Flasher](${buttonUrl})](${flasherUrl})`,
'',
badges.join(' '),
'',
'> [!WARNING]',
'> This is an automated, unreviewed CI test build. Back up your device configuration',
'> before flashing, and only flash devices you are able to recover.',
'',
...boardTable,
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
].join('\n');
// Sticky comment: update in place when the marker is found
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber, per_page: 100,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
@@ -1,62 +0,0 @@
name: Post Web Flasher Build Placeholder
# Drops an immediate "build in progress" comment when a PR opens, so the web
# flasher entry shows up right away. The real CI-driven workflow
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
#
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
# safe because it never checks out or runs PR code and posts a fully static body
# — no PR title, branch name, or other untrusted input is used anywhere.
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
post-placeholder:
if: github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Post web flasher build-in-progress placeholder
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
// Trusted authors only (matches the real workflow). author_association
// can't reflect private org membership for the token, so concealed
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Only seed a placeholder when no flasher comment exists yet — never
// overwrite a real (or existing placeholder) comment.
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
if (comments.some((c) => c.body?.includes(marker))) {
core.info('Flasher comment already exists; nothing to do.');
return;
}
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
'> [!NOTE]',
'> Building this pull request… the flash button, badges and supported-board',
'> list will appear here automatically once CI finishes.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
+6 -7
View File
@@ -82,9 +82,8 @@ jobs:
fail-fast: false
matrix:
check: ${{ fromJson(needs.setup.outputs.check) }}
# Runs on GitHub-hosted runners so checks don't compete with builds for the
# self-hosted 'arctastic' pool (which builds use).
runs-on: ubuntu-latest
# Use 'arctastic' self-hosted runner pool when checking in the main repo
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
steps:
- uses: actions/checkout@v6
@@ -287,11 +286,11 @@ jobs:
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
cp "./baseline-develop/current-sizes.json" ./develop-sizes.json
cp "./baseline-develop/${ARTIFACT_NAME}/current-sizes.json" ./develop-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
@@ -312,11 +311,11 @@ jobs:
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-master/
cp "./baseline-master/current-sizes.json" ./master-sizes.json
cp "./baseline-master/${ARTIFACT_NAME}/current-sizes.json" ./master-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
+1 -21
View File
@@ -37,33 +37,13 @@ jobs:
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
- name: Integration test
# Cap the whole step: if the simulator ever fails to exit (e.g. the
# exit_simulator admin path regresses again) the job must fail fast,
# not run to GitHub's 6-hour limit.
timeout-minutes: 5
run: |
.pio/build/coverage/meshtasticd -s &
PID=$!
trap 'kill "$PID" 2>/dev/null || true' EXIT
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
echo "Simulator started, launching python test..."
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
# The Python harness sends exit_simulator and exits; the simulator is
# expected to terminate on its own. Give it a moment, then verify.
# If it is still alive the exit handshake is broken — fail loudly and
# do NOT fall through to `wait`, which would otherwise block until the
# job's hard timeout.
for i in $(seq 1 10); do
kill -0 "$PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$PID" 2>/dev/null; then
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
kill -9 "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
exit 1
fi
wait "$PID" 2>/dev/null || true
wait
- name: Capture coverage information
if: always() # run this step even if previous step failed
+4 -9
View File
@@ -16,18 +16,13 @@ jobs:
submodules: true
- name: Update submodule
if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }}
working-directory: protobufs
env:
# Use the branch that triggered the workflow as the protobuf branch.
GIT_BRANCH: ${{ github.ref_name }}
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
run: |
git fetch --prune origin $GIT_BRANCH
git checkout origin/$GIT_BRANCH
git submodule update --remote protobufs
- name: Download nanopb
run: |
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz
mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9
@@ -38,7 +33,7 @@ jobs:
- name: Create pull request
uses: peter-evans/create-pull-request@v8
with:
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
branch: create-pull-request/update-protobufs
labels: submodules
title: Update protobufs and classes
commit-message: Update protobufs
+1 -1
View File
@@ -64,7 +64,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
+50
View File
@@ -0,0 +1,50 @@
{
"build": {
"arduino": {
"ldscript": "nrf52832_s132_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DNRF52832_XXAA -DNRF52",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x8029"],
["0x239A", "0x0029"],
["0x239A", "0x002A"],
["0x239A", "0x802A"]
],
"usb_product": "Feather nRF52832 Express",
"mcu": "nrf52832",
"variant": "WisCore_RAK4600_Board",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS132",
"sd_name": "s132",
"sd_version": "6.1.1",
"sd_fwid": "0x00B7"
},
"zephyr": {
"variant": "nrf52_adafruit_feather"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52832_xxAA",
"svd_path": "nrf52.svd",
"openocd_target": "nrf52840-mdk-rs"
},
"frameworks": ["arduino", "zephyr"],
"name": "Adafruit Bluefruit nRF52832 Feather",
"upload": {
"maximum_ram_size": 65536,
"maximum_size": 524288,
"require_upload_port": true,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
},
"url": "https://www.adafruit.com/product/3406",
"vendor": "Adafruit"
}
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821)
#
# Whole-image LTO for nrf52840 (~-60KB; ~-23KB beyond src-only LTO), EXCEPT the objects
# that own interrupt/exception handlers.
#
# Every ISR is referenced only from the assembly vector table (gcc_startup_nrf52840.S),
# which LTO cannot see -> whole-program LTO judges the handlers dead, removes them, and
# the weak `b .` Default_Handler stubs prevail -> the IRQ lands in an infinite loop and the
# chip hangs (or the peripheral silently stalls). Compiling the handler-bearing objects
# WITHOUT LTO lets ordinary linking keep the strong handlers; everything else stays LTO'd:
# - framework core (/FrameworkArduino/, /cores/nRF5/): every nrfx ISR + the FreeRTOS
# SVC/PendSV port.
# - TinyUSB nrf port (Adafruit_TinyUSB_nrf.cpp): USBD_IRQHandler (USB data path).
# - library .cpp files that own a vector ISR (would otherwise be silently dropped):
# bluefruit.cpp -> SD_EVT/SWI2_EGU2 (SoftDevice BLE-event delivery -- advertising
# hangs without it)
# Wire_nRF52.cpp -> SPIM0/TWIM0 + SPIM1/TWIM1 (interrupt-driven I2C/SPI)
# PDM.cpp -> PDM_IRQHandler (PDM microphone)
# RotaryEncoder.cpp -> QDEC_IRQHandler (hardware quadrature/rotary encoder)
#
# A post-link guard (bottom of this file) fails the build if a critical handler was dropped
# anyway -- so a future deps bump or a new ISR-owning library becomes a red build, not a field
# hang. To hunt a dropped ISR by hand: nm the .elf for `_IRQHandler$` symbols marked `W`, then
# grep the libs/framework for who defines them.
#
# HW-validated: RAK4631 (SX1262) + muzi-base (LR1121).
import glob
import os
Import("env")
env.Append(LINKFLAGS=["-flto", "-flto-partition=1to1"])
# The -fno-lto re-compiles below run with the global env, which lacks the framework's
# bundled-library include dirs -- and those libs cross-include each other (Wire pulls in
# Adafruit_TinyUSB.h, which pulls in SPI.h, ...). Add every bundled-lib dir (+ its src/) so
# the re-compiles resolve without chasing headers one at a time.
_fw = env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") or ""
_extra_inc = []
for _d in sorted(glob.glob(os.path.join(_fw, "libraries", "*"))):
if os.path.isdir(_d):
_extra_inc.append(_d)
if os.path.isdir(os.path.join(_d, "src")):
_extra_inc.append(os.path.join(_d, "src"))
FRAMEWORK = ("/FrameworkArduino/", "/cores/nRF5/")
USB_ISR = "Adafruit_TinyUSB_nrf" # USBD_IRQHandler
# Library .cpp files that define vector-table ISRs (the rest of their lib stays LTO'd):
LIB_ISR = ("/bluefruit.cpp", "/Wire_nRF52.cpp", "/PDM.cpp", "/RotaryEncoder.cpp")
def _no_lto(node):
try:
path = node.get_abspath()
except Exception:
path = str(node)
path = path.replace(
"\\", "/"
) # normalize Windows backslashes so matches work cross-platform
if (
USB_ISR in path
or any(s in path for s in FRAMEWORK)
or any(s in path for s in LIB_ISR)
):
return env.Object(
node,
CCFLAGS=env["CCFLAGS"] + ["-fno-lto"],
CPPPATH=env["CPPPATH"] + _extra_inc,
)
return node
env.AddBuildMiddleware(_no_lto)
# --- post-link guard: catch a dropped ISR handler at build time (CI footgun protection) ----
# After every link, fail the build if one of these critical vector-table handlers resolved to
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
# target, so this runs on every PR automatically. If a board deliberately stops using one of
# these, edit the tuples on purpose.
_REQUIRED_STRONG = (
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
"RTC1_IRQHandler", # FreeRTOS scheduler tick
)
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
_REQUIRED_STRONG_USB = (
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
)
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
if not os.path.isfile(_NM):
_NM = "arm-none-eabi-nm" # fall back to PATH
def _assert_isr_handlers_survived(source, target, env):
import subprocess
import sys
try:
# Resolve the ELF at build time; target[0] is the buildprog alias, not the file.
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
out = subprocess.check_output([_NM, elf], universal_newlines=True)
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
print("nrf52_lto: WARNING - ISR-handler guard skipped (nm failed: %s)" % exc)
return
# nm line: "<addr> <type> <symbol>". type 'T'/'t' = strong (good); 'W'/'w' = weak stub.
kind = {}
for line in out.split("\n"):
f = line.split()
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
kind[f[-1]] = f[-2]
required = list(_REQUIRED_STRONG)
defines = [
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
]
if "USE_TINYUSB" in defines:
required += _REQUIRED_STRONG_USB
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
if dropped:
sys.stderr.write(
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
"Each resolved to the weak Default_Handler stub, so the chip hangs when that IRQ\n"
"fires. Compile the .cpp that defines the handler with -fno-lto by adding it to\n"
"LIB_ISR in extra_scripts/nrf52_lto.py. Find the owner of FOO_IRQHandler with:\n"
" grep -rl FOO_IRQHandler <framework-arduinoadafruitnrf52>/{libraries,cores}\n\n"
% ", ".join(dropped)
)
from SCons.Script import Exit
Exit(1) # canonical SCons build-abort -> red build
print(
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
)
# Attach to the phony "buildprog" alias, NOT the .elf file node: SCons can skip a post-action
# on a file target during an incremental relink (observed), but the buildprog alias runs every
# build -- so the guard fires on local incremental rebuilds and clean CI builds alike.
env.AddPostAction("buildprog", _assert_isr_handlers_survived)
+1 -1
View File
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
"esp32-c6",
"esp32c6",
}
_NRF52_ARCHES = {"nrf52", "nrf52840"}
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
+22 -3
View File
@@ -8,10 +8,30 @@ extra_configs =
variants/*/*.ini
variants/*/*/platformio.ini
variants/*/diy/*/platformio.ini
src/graphics/niche/InkHUD/PlatformioConfig.ini
description = Meshtastic
; E-Ink / NicheGraphics build helpers.
[niche]
build_src_filter =
+<graphics/eink/>
build_flags =
-D MESHTASTIC_INCLUDE_NICHE_GRAPHICS
[inkhud]
build_src_filter =
${niche.build_src_filter}
+<graphics/niche/>
build_flags =
${niche.build_flags}
-D MESHTASTIC_INCLUDE_INKHUD ; Use InkHUD as the UI
-D MESHTASTIC_EXCLUDE_SCREEN ; Suppress default Screen class
-D MESHTASTIC_EXCLUDE_INPUTBROKER ; Suppress default input handling
-D HAS_BUTTON=0 ; Suppress default ButtonThread
lib_deps =
# renovate: datasource=github-tags depName=GFX_Root packageName=ZinggJM/GFX_Root
https://github.com/ZinggJM/GFX_Root/archive/3195764e352a0d2567c8d277ac408ca7293a99b0.zip ; Used by InkHUD as a "slimmer" version of AdafruitGFX
[env]
test_build_src = true
extra_scripts =
@@ -103,7 +123,7 @@ build_unflags =
-std=gnu++11
build_flags = ${env.build_flags} -Os
-std=gnu++17
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/>
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/> -<graphics/eink/>
; Common libs for communicating over TCP/IP networks such as MQTT
[networking_base]
@@ -200,7 +220,6 @@ lib_deps =
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
https://github.com/xioTechnologies/Fusion/archive/a93c0dc83ce3ab65246f63ba134d3c2a15d6cabf.zip
; Common environmental sensor libraries (not included in native / portduino)
[environmental_extra_common]
+2 -2
View File
@@ -14,8 +14,8 @@
#define FILE_O_READ "r"
#endif
#if defined(ARCH_STM32)
// STM32
#if defined(ARCH_STM32WL)
// STM32WL
#include "LittleFS.h"
#define FSCom InternalFS
#define FSBegin() FSCom.begin()
+32
View File
@@ -0,0 +1,32 @@
/**
* @file Fusion.h
* @author Seb Madgwick
* @brief Main header file for the Fusion library. This is the only file that
* needs to be included when using the library.
*/
#ifndef FUSION_H
#define FUSION_H
//------------------------------------------------------------------------------
// Includes
#ifdef __cplusplus
extern "C" {
#endif
#include "FusionAhrs.h"
#include "FusionAxes.h"
#include "FusionCalibration.h"
#include "FusionCompass.h"
#include "FusionConvention.h"
#include "FusionMath.h"
#include "FusionOffset.h"
#ifdef __cplusplus
}
#endif
#endif
//------------------------------------------------------------------------------
// End of file
+542
View File
@@ -0,0 +1,542 @@
/**
* @file FusionAhrs.c
* @author Seb Madgwick
* @brief AHRS algorithm to combine gyroscope, accelerometer, and magnetometer
* measurements into a single measurement of orientation relative to the Earth.
*/
//------------------------------------------------------------------------------
// Includes
#include "FusionAhrs.h"
#include <float.h> // FLT_MAX
#include <math.h> // atan2f, cosf, fabsf, powf, sinf
//------------------------------------------------------------------------------
// Definitions
/**
* @brief Initial gain used during the initialisation.
*/
#define INITIAL_GAIN (10.0f)
/**
* @brief Initialisation period in seconds.
*/
#define INITIALISATION_PERIOD (3.0f)
//------------------------------------------------------------------------------
// Function declarations
static inline FusionVector HalfGravity(const FusionAhrs *const ahrs);
static inline FusionVector HalfMagnetic(const FusionAhrs *const ahrs);
static inline FusionVector Feedback(const FusionVector sensor, const FusionVector reference);
static inline int Clamp(const int value, const int min, const int max);
//------------------------------------------------------------------------------
// Functions
/**
* @brief Initialises the AHRS algorithm structure.
* @param ahrs AHRS algorithm structure.
*/
void FusionAhrsInitialise(FusionAhrs *const ahrs)
{
const FusionAhrsSettings settings = {
.convention = FusionConventionNwu,
.gain = 0.5f,
.gyroscopeRange = 0.0f,
.accelerationRejection = 90.0f,
.magneticRejection = 90.0f,
.recoveryTriggerPeriod = 0,
};
FusionAhrsSetSettings(ahrs, &settings);
FusionAhrsReset(ahrs);
}
/**
* @brief Resets the AHRS algorithm. This is equivalent to reinitialising the
* algorithm while maintaining the current settings.
* @param ahrs AHRS algorithm structure.
*/
void FusionAhrsReset(FusionAhrs *const ahrs)
{
ahrs->quaternion = FUSION_IDENTITY_QUATERNION;
ahrs->accelerometer = FUSION_VECTOR_ZERO;
ahrs->initialising = true;
ahrs->rampedGain = INITIAL_GAIN;
ahrs->angularRateRecovery = false;
ahrs->halfAccelerometerFeedback = FUSION_VECTOR_ZERO;
ahrs->halfMagnetometerFeedback = FUSION_VECTOR_ZERO;
ahrs->accelerometerIgnored = false;
ahrs->accelerationRecoveryTrigger = 0;
ahrs->accelerationRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
ahrs->magnetometerIgnored = false;
ahrs->magneticRecoveryTrigger = 0;
ahrs->magneticRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
}
/**
* @brief Sets the AHRS algorithm settings.
* @param ahrs AHRS algorithm structure.
* @param settings Settings.
*/
void FusionAhrsSetSettings(FusionAhrs *const ahrs, const FusionAhrsSettings *const settings)
{
ahrs->settings.convention = settings->convention;
ahrs->settings.gain = settings->gain;
ahrs->settings.gyroscopeRange = settings->gyroscopeRange == 0.0f ? FLT_MAX : 0.98f * settings->gyroscopeRange;
ahrs->settings.accelerationRejection = settings->accelerationRejection == 0.0f
? FLT_MAX
: powf(0.5f * sinf(FusionDegreesToRadians(settings->accelerationRejection)), 2);
ahrs->settings.magneticRejection =
settings->magneticRejection == 0.0f ? FLT_MAX : powf(0.5f * sinf(FusionDegreesToRadians(settings->magneticRejection)), 2);
ahrs->settings.recoveryTriggerPeriod = settings->recoveryTriggerPeriod;
ahrs->accelerationRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
ahrs->magneticRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
if ((settings->gain == 0.0f) ||
(settings->recoveryTriggerPeriod == 0)) { // disable acceleration and magnetic rejection features if gain is zero
ahrs->settings.accelerationRejection = FLT_MAX;
ahrs->settings.magneticRejection = FLT_MAX;
}
if (ahrs->initialising == false) {
ahrs->rampedGain = ahrs->settings.gain;
}
ahrs->rampedGainStep = (INITIAL_GAIN - ahrs->settings.gain) / INITIALISATION_PERIOD;
}
/**
* @brief Updates the AHRS algorithm using the gyroscope, accelerometer, and
* magnetometer measurements.
* @param ahrs AHRS algorithm structure.
* @param gyroscope Gyroscope measurement in degrees per second.
* @param accelerometer Accelerometer measurement in g.
* @param magnetometer Magnetometer measurement in arbitrary units.
* @param deltaTime Delta time in seconds.
*/
void FusionAhrsUpdate(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
const FusionVector magnetometer, const float deltaTime)
{
#define Q ahrs->quaternion.element
// Store accelerometer
ahrs->accelerometer = accelerometer;
// Reinitialise if gyroscope range exceeded
if ((fabsf(gyroscope.axis.x) > ahrs->settings.gyroscopeRange) || (fabsf(gyroscope.axis.y) > ahrs->settings.gyroscopeRange) ||
(fabsf(gyroscope.axis.z) > ahrs->settings.gyroscopeRange)) {
const FusionQuaternion quaternion = ahrs->quaternion;
FusionAhrsReset(ahrs);
ahrs->quaternion = quaternion;
ahrs->angularRateRecovery = true;
}
// Ramp down gain during initialisation
if (ahrs->initialising) {
ahrs->rampedGain -= ahrs->rampedGainStep * deltaTime;
if ((ahrs->rampedGain < ahrs->settings.gain) || (ahrs->settings.gain == 0.0f)) {
ahrs->rampedGain = ahrs->settings.gain;
ahrs->initialising = false;
ahrs->angularRateRecovery = false;
}
}
// Calculate direction of gravity indicated by algorithm
const FusionVector halfGravity = HalfGravity(ahrs);
// Calculate accelerometer feedback
FusionVector halfAccelerometerFeedback = FUSION_VECTOR_ZERO;
ahrs->accelerometerIgnored = true;
if (FusionVectorIsZero(accelerometer) == false) {
// Calculate accelerometer feedback scaled by 0.5
ahrs->halfAccelerometerFeedback = Feedback(FusionVectorNormalise(accelerometer), halfGravity);
// Don't ignore accelerometer if acceleration error below threshold
if (ahrs->initialising ||
((FusionVectorMagnitudeSquared(ahrs->halfAccelerometerFeedback) <= ahrs->settings.accelerationRejection))) {
ahrs->accelerometerIgnored = false;
ahrs->accelerationRecoveryTrigger -= 9;
} else {
ahrs->accelerationRecoveryTrigger += 1;
}
// Don't ignore accelerometer during acceleration recovery
if (ahrs->accelerationRecoveryTrigger > ahrs->accelerationRecoveryTimeout) {
ahrs->accelerationRecoveryTimeout = 0;
ahrs->accelerometerIgnored = false;
} else {
ahrs->accelerationRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
}
ahrs->accelerationRecoveryTrigger = Clamp(ahrs->accelerationRecoveryTrigger, 0, ahrs->settings.recoveryTriggerPeriod);
// Apply accelerometer feedback
if (ahrs->accelerometerIgnored == false) {
halfAccelerometerFeedback = ahrs->halfAccelerometerFeedback;
}
}
// Calculate magnetometer feedback
FusionVector halfMagnetometerFeedback = FUSION_VECTOR_ZERO;
ahrs->magnetometerIgnored = true;
if (FusionVectorIsZero(magnetometer) == false) {
// Calculate direction of magnetic field indicated by algorithm
const FusionVector halfMagnetic = HalfMagnetic(ahrs);
// Calculate magnetometer feedback scaled by 0.5
ahrs->halfMagnetometerFeedback =
Feedback(FusionVectorNormalise(FusionVectorCrossProduct(halfGravity, magnetometer)), halfMagnetic);
// Don't ignore magnetometer if magnetic error below threshold
if (ahrs->initialising ||
((FusionVectorMagnitudeSquared(ahrs->halfMagnetometerFeedback) <= ahrs->settings.magneticRejection))) {
ahrs->magnetometerIgnored = false;
ahrs->magneticRecoveryTrigger -= 9;
} else {
ahrs->magneticRecoveryTrigger += 1;
}
// Don't ignore magnetometer during magnetic recovery
if (ahrs->magneticRecoveryTrigger > ahrs->magneticRecoveryTimeout) {
ahrs->magneticRecoveryTimeout = 0;
ahrs->magnetometerIgnored = false;
} else {
ahrs->magneticRecoveryTimeout = ahrs->settings.recoveryTriggerPeriod;
}
ahrs->magneticRecoveryTrigger = Clamp(ahrs->magneticRecoveryTrigger, 0, ahrs->settings.recoveryTriggerPeriod);
// Apply magnetometer feedback
if (ahrs->magnetometerIgnored == false) {
halfMagnetometerFeedback = ahrs->halfMagnetometerFeedback;
}
}
// Convert gyroscope to radians per second scaled by 0.5
const FusionVector halfGyroscope = FusionVectorMultiplyScalar(gyroscope, FusionDegreesToRadians(0.5f));
// Apply feedback to gyroscope
const FusionVector adjustedHalfGyroscope = FusionVectorAdd(
halfGyroscope,
FusionVectorMultiplyScalar(FusionVectorAdd(halfAccelerometerFeedback, halfMagnetometerFeedback), ahrs->rampedGain));
// Integrate rate of change of quaternion
ahrs->quaternion = FusionQuaternionAdd(
ahrs->quaternion,
FusionQuaternionMultiplyVector(ahrs->quaternion, FusionVectorMultiplyScalar(adjustedHalfGyroscope, deltaTime)));
// Normalise quaternion
ahrs->quaternion = FusionQuaternionNormalise(ahrs->quaternion);
#undef Q
}
/**
* @brief Returns the direction of gravity scaled by 0.5.
* @param ahrs AHRS algorithm structure.
* @return Direction of gravity scaled by 0.5.
*/
static inline FusionVector HalfGravity(const FusionAhrs *const ahrs)
{
#define Q ahrs->quaternion.element
switch (ahrs->settings.convention) {
case FusionConventionNwu:
case FusionConventionEnu: {
const FusionVector halfGravity = {.axis = {
.x = Q.x * Q.z - Q.w * Q.y,
.y = Q.y * Q.z + Q.w * Q.x,
.z = Q.w * Q.w - 0.5f + Q.z * Q.z,
}}; // third column of transposed rotation matrix scaled by 0.5
return halfGravity;
}
case FusionConventionNed: {
const FusionVector halfGravity = {.axis = {
.x = Q.w * Q.y - Q.x * Q.z,
.y = -1.0f * (Q.y * Q.z + Q.w * Q.x),
.z = 0.5f - Q.w * Q.w - Q.z * Q.z,
}}; // third column of transposed rotation matrix scaled by -0.5
return halfGravity;
}
}
return FUSION_VECTOR_ZERO; // avoid compiler warning
#undef Q
}
/**
* @brief Returns the direction of the magnetic field scaled by 0.5.
* @param ahrs AHRS algorithm structure.
* @return Direction of the magnetic field scaled by 0.5.
*/
static inline FusionVector HalfMagnetic(const FusionAhrs *const ahrs)
{
#define Q ahrs->quaternion.element
switch (ahrs->settings.convention) {
case FusionConventionNwu: {
const FusionVector halfMagnetic = {.axis = {
.x = Q.x * Q.y + Q.w * Q.z,
.y = Q.w * Q.w - 0.5f + Q.y * Q.y,
.z = Q.y * Q.z - Q.w * Q.x,
}}; // second column of transposed rotation matrix scaled by 0.5
return halfMagnetic;
}
case FusionConventionEnu: {
const FusionVector halfMagnetic = {.axis = {
.x = 0.5f - Q.w * Q.w - Q.x * Q.x,
.y = Q.w * Q.z - Q.x * Q.y,
.z = -1.0f * (Q.x * Q.z + Q.w * Q.y),
}}; // first column of transposed rotation matrix scaled by -0.5
return halfMagnetic;
}
case FusionConventionNed: {
const FusionVector halfMagnetic = {.axis = {
.x = -1.0f * (Q.x * Q.y + Q.w * Q.z),
.y = 0.5f - Q.w * Q.w - Q.y * Q.y,
.z = Q.w * Q.x - Q.y * Q.z,
}}; // second column of transposed rotation matrix scaled by -0.5
return halfMagnetic;
}
}
return FUSION_VECTOR_ZERO; // avoid compiler warning
#undef Q
}
/**
* @brief Returns the feedback.
* @param sensor Sensor.
* @param reference Reference.
* @return Feedback.
*/
static inline FusionVector Feedback(const FusionVector sensor, const FusionVector reference)
{
if (FusionVectorDotProduct(sensor, reference) < 0.0f) { // if error is >90 degrees
return FusionVectorNormalise(FusionVectorCrossProduct(sensor, reference));
}
return FusionVectorCrossProduct(sensor, reference);
}
/**
* @brief Returns a value limited to maximum and minimum.
* @param value Value.
* @param min Minimum value.
* @param max Maximum value.
* @return Value limited to maximum and minimum.
*/
static inline int Clamp(const int value, const int min, const int max)
{
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
/**
* @brief Updates the AHRS algorithm using the gyroscope and accelerometer
* measurements only.
* @param ahrs AHRS algorithm structure.
* @param gyroscope Gyroscope measurement in degrees per second.
* @param accelerometer Accelerometer measurement in g.
* @param deltaTime Delta time in seconds.
*/
void FusionAhrsUpdateNoMagnetometer(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
const float deltaTime)
{
// Update AHRS algorithm
FusionAhrsUpdate(ahrs, gyroscope, accelerometer, FUSION_VECTOR_ZERO, deltaTime);
// Zero heading during initialisation
if (ahrs->initialising) {
FusionAhrsSetHeading(ahrs, 0.0f);
}
}
/**
* @brief Updates the AHRS algorithm using the gyroscope, accelerometer, and
* heading measurements.
* @param ahrs AHRS algorithm structure.
* @param gyroscope Gyroscope measurement in degrees per second.
* @param accelerometer Accelerometer measurement in g.
* @param heading Heading measurement in degrees.
* @param deltaTime Delta time in seconds.
*/
void FusionAhrsUpdateExternalHeading(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
const float heading, const float deltaTime)
{
#define Q ahrs->quaternion.element
// Calculate roll
const float roll = atan2f(Q.w * Q.x + Q.y * Q.z, 0.5f - Q.y * Q.y - Q.x * Q.x);
// Calculate magnetometer
const float headingRadians = FusionDegreesToRadians(heading);
const float sinHeadingRadians = sinf(headingRadians);
const FusionVector magnetometer = {.axis = {
.x = cosf(headingRadians),
.y = -1.0f * cosf(roll) * sinHeadingRadians,
.z = sinHeadingRadians * sinf(roll),
}};
// Update AHRS algorithm
FusionAhrsUpdate(ahrs, gyroscope, accelerometer, magnetometer, deltaTime);
#undef Q
}
/**
* @brief Returns the quaternion describing the sensor relative to the Earth.
* @param ahrs AHRS algorithm structure.
* @return Quaternion describing the sensor relative to the Earth.
*/
FusionQuaternion FusionAhrsGetQuaternion(const FusionAhrs *const ahrs)
{
return ahrs->quaternion;
}
/**
* @brief Sets the quaternion describing the sensor relative to the Earth.
* @param ahrs AHRS algorithm structure.
* @param quaternion Quaternion describing the sensor relative to the Earth.
*/
void FusionAhrsSetQuaternion(FusionAhrs *const ahrs, const FusionQuaternion quaternion)
{
ahrs->quaternion = quaternion;
}
/**
* @brief Returns the linear acceleration measurement equal to the accelerometer
* measurement with the 1 g of gravity removed.
* @param ahrs AHRS algorithm structure.
* @return Linear acceleration measurement in g.
*/
FusionVector FusionAhrsGetLinearAcceleration(const FusionAhrs *const ahrs)
{
#define Q ahrs->quaternion.element
// Calculate gravity in the sensor coordinate frame
const FusionVector gravity = {.axis = {
.x = 2.0f * (Q.x * Q.z - Q.w * Q.y),
.y = 2.0f * (Q.y * Q.z + Q.w * Q.x),
.z = 2.0f * (Q.w * Q.w - 0.5f + Q.z * Q.z),
}}; // third column of transposed rotation matrix
// Remove gravity from accelerometer measurement
switch (ahrs->settings.convention) {
case FusionConventionNwu:
case FusionConventionEnu: {
return FusionVectorSubtract(ahrs->accelerometer, gravity);
}
case FusionConventionNed: {
return FusionVectorAdd(ahrs->accelerometer, gravity);
}
}
return FUSION_VECTOR_ZERO; // avoid compiler warning
#undef Q
}
/**
* @brief Returns the Earth acceleration measurement equal to accelerometer
* measurement in the Earth coordinate frame with the 1 g of gravity removed.
* @param ahrs AHRS algorithm structure.
* @return Earth acceleration measurement in g.
*/
FusionVector FusionAhrsGetEarthAcceleration(const FusionAhrs *const ahrs)
{
#define Q ahrs->quaternion.element
#define A ahrs->accelerometer.axis
// Calculate accelerometer measurement in the Earth coordinate frame
const float qwqw = Q.w * Q.w; // calculate common terms to avoid repeated operations
const float qwqx = Q.w * Q.x;
const float qwqy = Q.w * Q.y;
const float qwqz = Q.w * Q.z;
const float qxqy = Q.x * Q.y;
const float qxqz = Q.x * Q.z;
const float qyqz = Q.y * Q.z;
FusionVector accelerometer = {.axis = {
.x = 2.0f * ((qwqw - 0.5f + Q.x * Q.x) * A.x + (qxqy - qwqz) * A.y + (qxqz + qwqy) * A.z),
.y = 2.0f * ((qxqy + qwqz) * A.x + (qwqw - 0.5f + Q.y * Q.y) * A.y + (qyqz - qwqx) * A.z),
.z = 2.0f * ((qxqz - qwqy) * A.x + (qyqz + qwqx) * A.y + (qwqw - 0.5f + Q.z * Q.z) * A.z),
}}; // rotation matrix multiplied with the accelerometer
// Remove gravity from accelerometer measurement
switch (ahrs->settings.convention) {
case FusionConventionNwu:
case FusionConventionEnu:
accelerometer.axis.z -= 1.0f;
break;
case FusionConventionNed:
accelerometer.axis.z += 1.0f;
break;
}
return accelerometer;
#undef Q
#undef A
}
/**
* @brief Returns the AHRS algorithm internal states.
* @param ahrs AHRS algorithm structure.
* @return AHRS algorithm internal states.
*/
FusionAhrsInternalStates FusionAhrsGetInternalStates(const FusionAhrs *const ahrs)
{
const FusionAhrsInternalStates internalStates = {
.accelerationError = FusionRadiansToDegrees(FusionAsin(2.0f * FusionVectorMagnitude(ahrs->halfAccelerometerFeedback))),
.accelerometerIgnored = ahrs->accelerometerIgnored,
.accelerationRecoveryTrigger =
ahrs->settings.recoveryTriggerPeriod == 0
? 0.0f
: (float)ahrs->accelerationRecoveryTrigger / (float)ahrs->settings.recoveryTriggerPeriod,
.magneticError = FusionRadiansToDegrees(FusionAsin(2.0f * FusionVectorMagnitude(ahrs->halfMagnetometerFeedback))),
.magnetometerIgnored = ahrs->magnetometerIgnored,
.magneticRecoveryTrigger = ahrs->settings.recoveryTriggerPeriod == 0
? 0.0f
: (float)ahrs->magneticRecoveryTrigger / (float)ahrs->settings.recoveryTriggerPeriod,
};
return internalStates;
}
/**
* @brief Returns the AHRS algorithm flags.
* @param ahrs AHRS algorithm structure.
* @return AHRS algorithm flags.
*/
FusionAhrsFlags FusionAhrsGetFlags(const FusionAhrs *const ahrs)
{
const FusionAhrsFlags flags = {
.initialising = ahrs->initialising,
.angularRateRecovery = ahrs->angularRateRecovery,
.accelerationRecovery = ahrs->accelerationRecoveryTrigger > ahrs->accelerationRecoveryTimeout,
.magneticRecovery = ahrs->magneticRecoveryTrigger > ahrs->magneticRecoveryTimeout,
};
return flags;
}
/**
* @brief Sets the heading of the orientation measurement provided by the AHRS
* algorithm. This function can be used to reset drift in heading when the AHRS
* algorithm is being used without a magnetometer.
* @param ahrs AHRS algorithm structure.
* @param heading Heading angle in degrees.
*/
void FusionAhrsSetHeading(FusionAhrs *const ahrs, const float heading)
{
#define Q ahrs->quaternion.element
const float yaw = atan2f(Q.w * Q.z + Q.x * Q.y, 0.5f - Q.y * Q.y - Q.z * Q.z);
const float halfYawMinusHeading = 0.5f * (yaw - FusionDegreesToRadians(heading));
const FusionQuaternion rotation = {.element = {
.w = cosf(halfYawMinusHeading),
.x = 0.0f,
.y = 0.0f,
.z = -1.0f * sinf(halfYawMinusHeading),
}};
ahrs->quaternion = FusionQuaternionMultiply(rotation, ahrs->quaternion);
#undef Q
}
//------------------------------------------------------------------------------
// End of file
+112
View File
@@ -0,0 +1,112 @@
/**
* @file FusionAhrs.h
* @author Seb Madgwick
* @brief AHRS algorithm to combine gyroscope, accelerometer, and magnetometer
* measurements into a single measurement of orientation relative to the Earth.
*/
#ifndef FUSION_AHRS_H
#define FUSION_AHRS_H
//------------------------------------------------------------------------------
// Includes
#include "FusionConvention.h"
#include "FusionMath.h"
#include <stdbool.h>
//------------------------------------------------------------------------------
// Definitions
/**
* @brief AHRS algorithm settings.
*/
typedef struct {
FusionConvention convention;
float gain;
float gyroscopeRange;
float accelerationRejection;
float magneticRejection;
unsigned int recoveryTriggerPeriod;
} FusionAhrsSettings;
/**
* @brief AHRS algorithm structure. Structure members are used internally and
* must not be accessed by the application.
*/
typedef struct {
FusionAhrsSettings settings;
FusionQuaternion quaternion;
FusionVector accelerometer;
bool initialising;
float rampedGain;
float rampedGainStep;
bool angularRateRecovery;
FusionVector halfAccelerometerFeedback;
FusionVector halfMagnetometerFeedback;
bool accelerometerIgnored;
int accelerationRecoveryTrigger;
int accelerationRecoveryTimeout;
bool magnetometerIgnored;
int magneticRecoveryTrigger;
int magneticRecoveryTimeout;
} FusionAhrs;
/**
* @brief AHRS algorithm internal states.
*/
typedef struct {
float accelerationError;
bool accelerometerIgnored;
float accelerationRecoveryTrigger;
float magneticError;
bool magnetometerIgnored;
float magneticRecoveryTrigger;
} FusionAhrsInternalStates;
/**
* @brief AHRS algorithm flags.
*/
typedef struct {
bool initialising;
bool angularRateRecovery;
bool accelerationRecovery;
bool magneticRecovery;
} FusionAhrsFlags;
//------------------------------------------------------------------------------
// Function declarations
void FusionAhrsInitialise(FusionAhrs *const ahrs);
void FusionAhrsReset(FusionAhrs *const ahrs);
void FusionAhrsSetSettings(FusionAhrs *const ahrs, const FusionAhrsSettings *const settings);
void FusionAhrsUpdate(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
const FusionVector magnetometer, const float deltaTime);
void FusionAhrsUpdateNoMagnetometer(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
const float deltaTime);
void FusionAhrsUpdateExternalHeading(FusionAhrs *const ahrs, const FusionVector gyroscope, const FusionVector accelerometer,
const float heading, const float deltaTime);
FusionQuaternion FusionAhrsGetQuaternion(const FusionAhrs *const ahrs);
void FusionAhrsSetQuaternion(FusionAhrs *const ahrs, const FusionQuaternion quaternion);
FusionVector FusionAhrsGetLinearAcceleration(const FusionAhrs *const ahrs);
FusionVector FusionAhrsGetEarthAcceleration(const FusionAhrs *const ahrs);
FusionAhrsInternalStates FusionAhrsGetInternalStates(const FusionAhrs *const ahrs);
FusionAhrsFlags FusionAhrsGetFlags(const FusionAhrs *const ahrs);
void FusionAhrsSetHeading(FusionAhrs *const ahrs, const float heading);
#endif
//------------------------------------------------------------------------------
// End of file
+188
View File
@@ -0,0 +1,188 @@
/**
* @file FusionAxes.h
* @author Seb Madgwick
* @brief Swaps sensor axes for alignment with the body axes.
*/
#ifndef FUSION_AXES_H
#define FUSION_AXES_H
//------------------------------------------------------------------------------
// Includes
#include "FusionMath.h"
//------------------------------------------------------------------------------
// Definitions
/**
* @brief Axes alignment describing the sensor axes relative to the body axes.
* For example, if the body X axis is aligned with the sensor Y axis and the
* body Y axis is aligned with sensor X axis but pointing the opposite direction
* then alignment is +Y-X+Z.
*/
typedef enum {
FusionAxesAlignmentPXPYPZ, /* +X+Y+Z */
FusionAxesAlignmentPXNZPY, /* +X-Z+Y */
FusionAxesAlignmentPXNYNZ, /* +X-Y-Z */
FusionAxesAlignmentPXPZNY, /* +X+Z-Y */
FusionAxesAlignmentNXPYNZ, /* -X+Y-Z */
FusionAxesAlignmentNXPZPY, /* -X+Z+Y */
FusionAxesAlignmentNXNYPZ, /* -X-Y+Z */
FusionAxesAlignmentNXNZNY, /* -X-Z-Y */
FusionAxesAlignmentPYNXPZ, /* +Y-X+Z */
FusionAxesAlignmentPYNZNX, /* +Y-Z-X */
FusionAxesAlignmentPYPXNZ, /* +Y+X-Z */
FusionAxesAlignmentPYPZPX, /* +Y+Z+X */
FusionAxesAlignmentNYPXPZ, /* -Y+X+Z */
FusionAxesAlignmentNYNZPX, /* -Y-Z+X */
FusionAxesAlignmentNYNXNZ, /* -Y-X-Z */
FusionAxesAlignmentNYPZNX, /* -Y+Z-X */
FusionAxesAlignmentPZPYNX, /* +Z+Y-X */
FusionAxesAlignmentPZPXPY, /* +Z+X+Y */
FusionAxesAlignmentPZNYPX, /* +Z-Y+X */
FusionAxesAlignmentPZNXNY, /* +Z-X-Y */
FusionAxesAlignmentNZPYPX, /* -Z+Y+X */
FusionAxesAlignmentNZNXPY, /* -Z-X+Y */
FusionAxesAlignmentNZNYNX, /* -Z-Y-X */
FusionAxesAlignmentNZPXNY, /* -Z+X-Y */
} FusionAxesAlignment;
//------------------------------------------------------------------------------
// Inline functions
/**
* @brief Swaps sensor axes for alignment with the body axes.
* @param sensor Sensor axes.
* @param alignment Axes alignment.
* @return Sensor axes aligned with the body axes.
*/
static inline FusionVector FusionAxesSwap(const FusionVector sensor, const FusionAxesAlignment alignment)
{
FusionVector result;
switch (alignment) {
case FusionAxesAlignmentPXPYPZ:
break;
case FusionAxesAlignmentPXNZPY:
result.axis.x = +sensor.axis.x;
result.axis.y = -sensor.axis.z;
result.axis.z = +sensor.axis.y;
return result;
case FusionAxesAlignmentPXNYNZ:
result.axis.x = +sensor.axis.x;
result.axis.y = -sensor.axis.y;
result.axis.z = -sensor.axis.z;
return result;
case FusionAxesAlignmentPXPZNY:
result.axis.x = +sensor.axis.x;
result.axis.y = +sensor.axis.z;
result.axis.z = -sensor.axis.y;
return result;
case FusionAxesAlignmentNXPYNZ:
result.axis.x = -sensor.axis.x;
result.axis.y = +sensor.axis.y;
result.axis.z = -sensor.axis.z;
return result;
case FusionAxesAlignmentNXPZPY:
result.axis.x = -sensor.axis.x;
result.axis.y = +sensor.axis.z;
result.axis.z = +sensor.axis.y;
return result;
case FusionAxesAlignmentNXNYPZ:
result.axis.x = -sensor.axis.x;
result.axis.y = -sensor.axis.y;
result.axis.z = +sensor.axis.z;
return result;
case FusionAxesAlignmentNXNZNY:
result.axis.x = -sensor.axis.x;
result.axis.y = -sensor.axis.z;
result.axis.z = -sensor.axis.y;
return result;
case FusionAxesAlignmentPYNXPZ:
result.axis.x = +sensor.axis.y;
result.axis.y = -sensor.axis.x;
result.axis.z = +sensor.axis.z;
return result;
case FusionAxesAlignmentPYNZNX:
result.axis.x = +sensor.axis.y;
result.axis.y = -sensor.axis.z;
result.axis.z = -sensor.axis.x;
return result;
case FusionAxesAlignmentPYPXNZ:
result.axis.x = +sensor.axis.y;
result.axis.y = +sensor.axis.x;
result.axis.z = -sensor.axis.z;
return result;
case FusionAxesAlignmentPYPZPX:
result.axis.x = +sensor.axis.y;
result.axis.y = +sensor.axis.z;
result.axis.z = +sensor.axis.x;
return result;
case FusionAxesAlignmentNYPXPZ:
result.axis.x = -sensor.axis.y;
result.axis.y = +sensor.axis.x;
result.axis.z = +sensor.axis.z;
return result;
case FusionAxesAlignmentNYNZPX:
result.axis.x = -sensor.axis.y;
result.axis.y = -sensor.axis.z;
result.axis.z = +sensor.axis.x;
return result;
case FusionAxesAlignmentNYNXNZ:
result.axis.x = -sensor.axis.y;
result.axis.y = -sensor.axis.x;
result.axis.z = -sensor.axis.z;
return result;
case FusionAxesAlignmentNYPZNX:
result.axis.x = -sensor.axis.y;
result.axis.y = +sensor.axis.z;
result.axis.z = -sensor.axis.x;
return result;
case FusionAxesAlignmentPZPYNX:
result.axis.x = +sensor.axis.z;
result.axis.y = +sensor.axis.y;
result.axis.z = -sensor.axis.x;
return result;
case FusionAxesAlignmentPZPXPY:
result.axis.x = +sensor.axis.z;
result.axis.y = +sensor.axis.x;
result.axis.z = +sensor.axis.y;
return result;
case FusionAxesAlignmentPZNYPX:
result.axis.x = +sensor.axis.z;
result.axis.y = -sensor.axis.y;
result.axis.z = +sensor.axis.x;
return result;
case FusionAxesAlignmentPZNXNY:
result.axis.x = +sensor.axis.z;
result.axis.y = -sensor.axis.x;
result.axis.z = -sensor.axis.y;
return result;
case FusionAxesAlignmentNZPYPX:
result.axis.x = -sensor.axis.z;
result.axis.y = +sensor.axis.y;
result.axis.z = +sensor.axis.x;
return result;
case FusionAxesAlignmentNZNXPY:
result.axis.x = -sensor.axis.z;
result.axis.y = -sensor.axis.x;
result.axis.z = +sensor.axis.y;
return result;
case FusionAxesAlignmentNZNYNX:
result.axis.x = -sensor.axis.z;
result.axis.y = -sensor.axis.y;
result.axis.z = -sensor.axis.x;
return result;
case FusionAxesAlignmentNZPXNY:
result.axis.x = -sensor.axis.z;
result.axis.y = +sensor.axis.x;
result.axis.z = -sensor.axis.y;
return result;
}
return sensor; // avoid compiler warning
}
#endif
//------------------------------------------------------------------------------
// End of file
+49
View File
@@ -0,0 +1,49 @@
/**
* @file FusionCalibration.h
* @author Seb Madgwick
* @brief Gyroscope, accelerometer, and magnetometer calibration models.
*/
#ifndef FUSION_CALIBRATION_H
#define FUSION_CALIBRATION_H
//------------------------------------------------------------------------------
// Includes
#include "FusionMath.h"
//------------------------------------------------------------------------------
// Inline functions
/**
* @brief Gyroscope and accelerometer calibration model.
* @param uncalibrated Uncalibrated measurement.
* @param misalignment Misalignment matrix.
* @param sensitivity Sensitivity.
* @param offset Offset.
* @return Calibrated measurement.
*/
static inline FusionVector FusionCalibrationInertial(const FusionVector uncalibrated, const FusionMatrix misalignment,
const FusionVector sensitivity, const FusionVector offset)
{
return FusionMatrixMultiplyVector(misalignment,
FusionVectorHadamardProduct(FusionVectorSubtract(uncalibrated, offset), sensitivity));
}
/**
* @brief Magnetometer calibration model.
* @param uncalibrated Uncalibrated measurement.
* @param softIronMatrix Soft-iron matrix.
* @param hardIronOffset Hard-iron offset.
* @return Calibrated measurement.
*/
static inline FusionVector FusionCalibrationMagnetic(const FusionVector uncalibrated, const FusionMatrix softIronMatrix,
const FusionVector hardIronOffset)
{
return FusionMatrixMultiplyVector(softIronMatrix, FusionVectorSubtract(uncalibrated, hardIronOffset));
}
#endif
//------------------------------------------------------------------------------
// End of file
+51
View File
@@ -0,0 +1,51 @@
/**
* @file FusionCompass.c
* @author Seb Madgwick
* @brief Tilt-compensated compass to calculate the magnetic heading using
* accelerometer and magnetometer measurements.
*/
//------------------------------------------------------------------------------
// Includes
#include "FusionCompass.h"
#include "FusionAxes.h"
#include <math.h> // atan2f
//------------------------------------------------------------------------------
// Functions
/**
* @brief Calculates the magnetic heading.
* @param convention Earth axes convention.
* @param accelerometer Accelerometer measurement in any calibrated units.
* @param magnetometer Magnetometer measurement in any calibrated units.
* @return Heading angle in degrees.
*/
float FusionCompassCalculateHeading(const FusionConvention convention, const FusionVector accelerometer,
const FusionVector magnetometer)
{
switch (convention) {
case FusionConventionNwu: {
const FusionVector west = FusionVectorNormalise(FusionVectorCrossProduct(accelerometer, magnetometer));
const FusionVector north = FusionVectorNormalise(FusionVectorCrossProduct(west, accelerometer));
return FusionRadiansToDegrees(atan2f(west.axis.x, north.axis.x));
}
case FusionConventionEnu: {
const FusionVector west = FusionVectorNormalise(FusionVectorCrossProduct(accelerometer, magnetometer));
const FusionVector north = FusionVectorNormalise(FusionVectorCrossProduct(west, accelerometer));
const FusionVector east = FusionVectorMultiplyScalar(west, -1.0f);
return FusionRadiansToDegrees(atan2f(north.axis.x, east.axis.x));
}
case FusionConventionNed: {
const FusionVector up = FusionVectorMultiplyScalar(accelerometer, -1.0f);
const FusionVector west = FusionVectorNormalise(FusionVectorCrossProduct(up, magnetometer));
const FusionVector north = FusionVectorNormalise(FusionVectorCrossProduct(west, up));
return FusionRadiansToDegrees(atan2f(west.axis.x, north.axis.x));
}
}
return 0; // avoid compiler warning
}
//------------------------------------------------------------------------------
// End of file
+26
View File
@@ -0,0 +1,26 @@
/**
* @file FusionCompass.h
* @author Seb Madgwick
* @brief Tilt-compensated compass to calculate the magnetic heading using
* accelerometer and magnetometer measurements.
*/
#ifndef FUSION_COMPASS_H
#define FUSION_COMPASS_H
//------------------------------------------------------------------------------
// Includes
#include "FusionConvention.h"
#include "FusionMath.h"
//------------------------------------------------------------------------------
// Function declarations
float FusionCompassCalculateHeading(const FusionConvention convention, const FusionVector accelerometer,
const FusionVector magnetometer);
#endif
//------------------------------------------------------------------------------
// End of file
+25
View File
@@ -0,0 +1,25 @@
/**
* @file FusionConvention.h
* @author Seb Madgwick
* @brief Earth axes convention.
*/
#ifndef FUSION_CONVENTION_H
#define FUSION_CONVENTION_H
//------------------------------------------------------------------------------
// Definitions
/**
* @brief Earth axes convention.
*/
typedef enum {
FusionConventionNwu, /* North-West-Up */
FusionConventionEnu, /* East-North-Up */
FusionConventionNed, /* North-East-Down */
} FusionConvention;
#endif
//------------------------------------------------------------------------------
// End of file
+503
View File
@@ -0,0 +1,503 @@
/**
* @file FusionMath.h
* @author Seb Madgwick
* @brief Math library.
*/
#ifndef FUSION_MATH_H
#define FUSION_MATH_H
//------------------------------------------------------------------------------
// Includes
#include <math.h> // M_PI, sqrtf, atan2f, asinf
#include <stdbool.h>
#include <stdint.h>
//------------------------------------------------------------------------------
// Definitions
/**
* @brief 3D vector.
*/
typedef union {
float array[3];
struct {
float x;
float y;
float z;
} axis;
} FusionVector;
/**
* @brief Quaternion.
*/
typedef union {
float array[4];
struct {
float w;
float x;
float y;
float z;
} element;
} FusionQuaternion;
/**
* @brief 3x3 matrix in row-major order.
* See http://en.wikipedia.org/wiki/Row-major_order
*/
typedef union {
float array[3][3];
struct {
float xx;
float xy;
float xz;
float yx;
float yy;
float yz;
float zx;
float zy;
float zz;
} element;
} FusionMatrix;
/**
* @brief Euler angles. Roll, pitch, and yaw correspond to rotations around
* X, Y, and Z respectively.
*/
typedef union {
float array[3];
struct {
float roll;
float pitch;
float yaw;
} angle;
} FusionEuler;
/**
* @brief Vector of zeros.
*/
#define FUSION_VECTOR_ZERO ((FusionVector){.array = {0.0f, 0.0f, 0.0f}})
/**
* @brief Vector of ones.
*/
#define FUSION_VECTOR_ONES ((FusionVector){.array = {1.0f, 1.0f, 1.0f}})
/**
* @brief Identity quaternion.
*/
#define FUSION_IDENTITY_QUATERNION ((FusionQuaternion){.array = {1.0f, 0.0f, 0.0f, 0.0f}})
/**
* @brief Identity matrix.
*/
#define FUSION_IDENTITY_MATRIX ((FusionMatrix){.array = {{1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}}})
/**
* @brief Euler angles of zero.
*/
#define FUSION_EULER_ZERO ((FusionEuler){.array = {0.0f, 0.0f, 0.0f}})
/**
* @brief Pi. May not be defined in math.h.
*/
#ifndef M_PI
#define M_PI (3.14159265358979323846)
#endif
/**
* @brief Include this definition or add as a preprocessor definition to use
* normal square root operations.
*/
// #define FUSION_USE_NORMAL_SQRT
//------------------------------------------------------------------------------
// Inline functions - Degrees and radians conversion
/**
* @brief Converts degrees to radians.
* @param degrees Degrees.
* @return Radians.
*/
static inline float FusionDegreesToRadians(const float degrees)
{
return degrees * ((float)M_PI / 180.0f);
}
/**
* @brief Converts radians to degrees.
* @param radians Radians.
* @return Degrees.
*/
static inline float FusionRadiansToDegrees(const float radians)
{
return radians * (180.0f / (float)M_PI);
}
//------------------------------------------------------------------------------
// Inline functions - Arc sine
/**
* @brief Returns the arc sine of the value.
* @param value Value.
* @return Arc sine of the value.
*/
static inline float FusionAsin(const float value)
{
if (value <= -1.0f) {
return (float)M_PI / -2.0f;
}
if (value >= 1.0f) {
return (float)M_PI / 2.0f;
}
return asinf(value);
}
//------------------------------------------------------------------------------
// Inline functions - Fast inverse square root
#ifndef FUSION_USE_NORMAL_SQRT
/**
* @brief Calculates the reciprocal of the square root.
* See https://pizer.wordpress.com/2008/10/12/fast-inverse-square-root/
* @param x Operand.
* @return Reciprocal of the square root of x.
*/
static inline float FusionFastInverseSqrt(const float x)
{
typedef union {
float f;
int32_t i;
} Union32;
Union32 union32 = {.f = x};
union32.i = 0x5F1F1412 - (union32.i >> 1);
return union32.f * (1.69000231f - 0.714158168f * x * union32.f * union32.f);
}
#endif
//------------------------------------------------------------------------------
// Inline functions - Vector operations
/**
* @brief Returns true if the vector is zero.
* @param vector Vector.
* @return True if the vector is zero.
*/
static inline bool FusionVectorIsZero(const FusionVector vector)
{
return (vector.axis.x == 0.0f) && (vector.axis.y == 0.0f) && (vector.axis.z == 0.0f);
}
/**
* @brief Returns the sum of two vectors.
* @param vectorA Vector A.
* @param vectorB Vector B.
* @return Sum of two vectors.
*/
static inline FusionVector FusionVectorAdd(const FusionVector vectorA, const FusionVector vectorB)
{
const FusionVector result = {.axis = {
.x = vectorA.axis.x + vectorB.axis.x,
.y = vectorA.axis.y + vectorB.axis.y,
.z = vectorA.axis.z + vectorB.axis.z,
}};
return result;
}
/**
* @brief Returns vector B subtracted from vector A.
* @param vectorA Vector A.
* @param vectorB Vector B.
* @return Vector B subtracted from vector A.
*/
static inline FusionVector FusionVectorSubtract(const FusionVector vectorA, const FusionVector vectorB)
{
const FusionVector result = {.axis = {
.x = vectorA.axis.x - vectorB.axis.x,
.y = vectorA.axis.y - vectorB.axis.y,
.z = vectorA.axis.z - vectorB.axis.z,
}};
return result;
}
/**
* @brief Returns the sum of the elements.
* @param vector Vector.
* @return Sum of the elements.
*/
static inline float FusionVectorSum(const FusionVector vector)
{
return vector.axis.x + vector.axis.y + vector.axis.z;
}
/**
* @brief Returns the multiplication of a vector by a scalar.
* @param vector Vector.
* @param scalar Scalar.
* @return Multiplication of a vector by a scalar.
*/
static inline FusionVector FusionVectorMultiplyScalar(const FusionVector vector, const float scalar)
{
const FusionVector result = {.axis = {
.x = vector.axis.x * scalar,
.y = vector.axis.y * scalar,
.z = vector.axis.z * scalar,
}};
return result;
}
/**
* @brief Calculates the Hadamard product (element-wise multiplication).
* @param vectorA Vector A.
* @param vectorB Vector B.
* @return Hadamard product.
*/
static inline FusionVector FusionVectorHadamardProduct(const FusionVector vectorA, const FusionVector vectorB)
{
const FusionVector result = {.axis = {
.x = vectorA.axis.x * vectorB.axis.x,
.y = vectorA.axis.y * vectorB.axis.y,
.z = vectorA.axis.z * vectorB.axis.z,
}};
return result;
}
/**
* @brief Returns the cross product.
* @param vectorA Vector A.
* @param vectorB Vector B.
* @return Cross product.
*/
static inline FusionVector FusionVectorCrossProduct(const FusionVector vectorA, const FusionVector vectorB)
{
#define A vectorA.axis
#define B vectorB.axis
const FusionVector result = {.axis = {
.x = A.y * B.z - A.z * B.y,
.y = A.z * B.x - A.x * B.z,
.z = A.x * B.y - A.y * B.x,
}};
return result;
#undef A
#undef B
}
/**
* @brief Returns the dot product.
* @param vectorA Vector A.
* @param vectorB Vector B.
* @return Dot product.
*/
static inline float FusionVectorDotProduct(const FusionVector vectorA, const FusionVector vectorB)
{
return FusionVectorSum(FusionVectorHadamardProduct(vectorA, vectorB));
}
/**
* @brief Returns the vector magnitude squared.
* @param vector Vector.
* @return Vector magnitude squared.
*/
static inline float FusionVectorMagnitudeSquared(const FusionVector vector)
{
return FusionVectorSum(FusionVectorHadamardProduct(vector, vector));
}
/**
* @brief Returns the vector magnitude.
* @param vector Vector.
* @return Vector magnitude.
*/
static inline float FusionVectorMagnitude(const FusionVector vector)
{
return sqrtf(FusionVectorMagnitudeSquared(vector));
}
/**
* @brief Returns the normalised vector.
* @param vector Vector.
* @return Normalised vector.
*/
static inline FusionVector FusionVectorNormalise(const FusionVector vector)
{
#ifdef FUSION_USE_NORMAL_SQRT
const float magnitudeReciprocal = 1.0f / sqrtf(FusionVectorMagnitudeSquared(vector));
#else
const float magnitudeReciprocal = FusionFastInverseSqrt(FusionVectorMagnitudeSquared(vector));
#endif
return FusionVectorMultiplyScalar(vector, magnitudeReciprocal);
}
//------------------------------------------------------------------------------
// Inline functions - Quaternion operations
/**
* @brief Returns the sum of two quaternions.
* @param quaternionA Quaternion A.
* @param quaternionB Quaternion B.
* @return Sum of two quaternions.
*/
static inline FusionQuaternion FusionQuaternionAdd(const FusionQuaternion quaternionA, const FusionQuaternion quaternionB)
{
const FusionQuaternion result = {.element = {
.w = quaternionA.element.w + quaternionB.element.w,
.x = quaternionA.element.x + quaternionB.element.x,
.y = quaternionA.element.y + quaternionB.element.y,
.z = quaternionA.element.z + quaternionB.element.z,
}};
return result;
}
/**
* @brief Returns the multiplication of two quaternions.
* @param quaternionA Quaternion A (to be post-multiplied).
* @param quaternionB Quaternion B (to be pre-multiplied).
* @return Multiplication of two quaternions.
*/
static inline FusionQuaternion FusionQuaternionMultiply(const FusionQuaternion quaternionA, const FusionQuaternion quaternionB)
{
#define A quaternionA.element
#define B quaternionB.element
const FusionQuaternion result = {.element = {
.w = A.w * B.w - A.x * B.x - A.y * B.y - A.z * B.z,
.x = A.w * B.x + A.x * B.w + A.y * B.z - A.z * B.y,
.y = A.w * B.y - A.x * B.z + A.y * B.w + A.z * B.x,
.z = A.w * B.z + A.x * B.y - A.y * B.x + A.z * B.w,
}};
return result;
#undef A
#undef B
}
/**
* @brief Returns the multiplication of a quaternion with a vector. This is a
* normal quaternion multiplication where the vector is treated a
* quaternion with a W element value of zero. The quaternion is post-
* multiplied by the vector.
* @param quaternion Quaternion.
* @param vector Vector.
* @return Multiplication of a quaternion with a vector.
*/
static inline FusionQuaternion FusionQuaternionMultiplyVector(const FusionQuaternion quaternion, const FusionVector vector)
{
#define Q quaternion.element
#define V vector.axis
const FusionQuaternion result = {.element = {
.w = -Q.x * V.x - Q.y * V.y - Q.z * V.z,
.x = Q.w * V.x + Q.y * V.z - Q.z * V.y,
.y = Q.w * V.y - Q.x * V.z + Q.z * V.x,
.z = Q.w * V.z + Q.x * V.y - Q.y * V.x,
}};
return result;
#undef Q
#undef V
}
/**
* @brief Returns the normalised quaternion.
* @param quaternion Quaternion.
* @return Normalised quaternion.
*/
static inline FusionQuaternion FusionQuaternionNormalise(const FusionQuaternion quaternion)
{
#define Q quaternion.element
#ifdef FUSION_USE_NORMAL_SQRT
const float magnitudeReciprocal = 1.0f / sqrtf(Q.w * Q.w + Q.x * Q.x + Q.y * Q.y + Q.z * Q.z);
#else
const float magnitudeReciprocal = FusionFastInverseSqrt(Q.w * Q.w + Q.x * Q.x + Q.y * Q.y + Q.z * Q.z);
#endif
const FusionQuaternion result = {.element = {
.w = Q.w * magnitudeReciprocal,
.x = Q.x * magnitudeReciprocal,
.y = Q.y * magnitudeReciprocal,
.z = Q.z * magnitudeReciprocal,
}};
return result;
#undef Q
}
//------------------------------------------------------------------------------
// Inline functions - Matrix operations
/**
* @brief Returns the multiplication of a matrix with a vector.
* @param matrix Matrix.
* @param vector Vector.
* @return Multiplication of a matrix with a vector.
*/
static inline FusionVector FusionMatrixMultiplyVector(const FusionMatrix matrix, const FusionVector vector)
{
#define R matrix.element
const FusionVector result = {.axis = {
.x = R.xx * vector.axis.x + R.xy * vector.axis.y + R.xz * vector.axis.z,
.y = R.yx * vector.axis.x + R.yy * vector.axis.y + R.yz * vector.axis.z,
.z = R.zx * vector.axis.x + R.zy * vector.axis.y + R.zz * vector.axis.z,
}};
return result;
#undef R
}
//------------------------------------------------------------------------------
// Inline functions - Conversion operations
/**
* @brief Converts a quaternion to a rotation matrix.
* @param quaternion Quaternion.
* @return Rotation matrix.
*/
static inline FusionMatrix FusionQuaternionToMatrix(const FusionQuaternion quaternion)
{
#define Q quaternion.element
const float qwqw = Q.w * Q.w; // calculate common terms to avoid repeated operations
const float qwqx = Q.w * Q.x;
const float qwqy = Q.w * Q.y;
const float qwqz = Q.w * Q.z;
const float qxqy = Q.x * Q.y;
const float qxqz = Q.x * Q.z;
const float qyqz = Q.y * Q.z;
const FusionMatrix matrix = {.element = {
.xx = 2.0f * (qwqw - 0.5f + Q.x * Q.x),
.xy = 2.0f * (qxqy - qwqz),
.xz = 2.0f * (qxqz + qwqy),
.yx = 2.0f * (qxqy + qwqz),
.yy = 2.0f * (qwqw - 0.5f + Q.y * Q.y),
.yz = 2.0f * (qyqz - qwqx),
.zx = 2.0f * (qxqz - qwqy),
.zy = 2.0f * (qyqz + qwqx),
.zz = 2.0f * (qwqw - 0.5f + Q.z * Q.z),
}};
return matrix;
#undef Q
}
/**
* @brief Converts a quaternion to ZYX Euler angles in degrees.
* @param quaternion Quaternion.
* @return Euler angles in degrees.
*/
static inline FusionEuler FusionQuaternionToEuler(const FusionQuaternion quaternion)
{
#define Q quaternion.element
const float halfMinusQySquared = 0.5f - Q.y * Q.y; // calculate common terms to avoid repeated operations
const FusionEuler euler = {.angle = {
.roll = FusionRadiansToDegrees(atan2f(Q.w * Q.x + Q.y * Q.z, halfMinusQySquared - Q.x * Q.x)),
.pitch = FusionRadiansToDegrees(FusionAsin(2.0f * (Q.w * Q.y - Q.z * Q.x))),
.yaw = FusionRadiansToDegrees(atan2f(Q.w * Q.z + Q.x * Q.y, halfMinusQySquared - Q.z * Q.z)),
}};
return euler;
#undef Q
}
#endif
//------------------------------------------------------------------------------
// End of file
+80
View File
@@ -0,0 +1,80 @@
/**
* @file FusionOffset.c
* @author Seb Madgwick
* @brief Gyroscope offset correction algorithm for run-time calibration of the
* gyroscope offset.
*/
//------------------------------------------------------------------------------
// Includes
#include "FusionOffset.h"
#include <math.h> // fabsf
//------------------------------------------------------------------------------
// Definitions
/**
* @brief Cutoff frequency in Hz.
*/
#define CUTOFF_FREQUENCY (0.02f)
/**
* @brief Timeout in seconds.
*/
#define TIMEOUT (5)
/**
* @brief Threshold in degrees per second.
*/
#define THRESHOLD (3.0f)
//------------------------------------------------------------------------------
// Functions
/**
* @brief Initialises the gyroscope offset algorithm.
* @param offset Gyroscope offset algorithm structure.
* @param sampleRate Sample rate in Hz.
*/
void FusionOffsetInitialise(FusionOffset *const offset, const unsigned int sampleRate)
{
offset->filterCoefficient = 2.0f * (float)M_PI * CUTOFF_FREQUENCY * (1.0f / (float)sampleRate);
offset->timeout = TIMEOUT * sampleRate;
offset->timer = 0;
offset->gyroscopeOffset = FUSION_VECTOR_ZERO;
}
/**
* @brief Updates the gyroscope offset algorithm and returns the corrected
* gyroscope measurement.
* @param offset Gyroscope offset algorithm structure.
* @param gyroscope Gyroscope measurement in degrees per second.
* @return Corrected gyroscope measurement in degrees per second.
*/
FusionVector FusionOffsetUpdate(FusionOffset *const offset, FusionVector gyroscope)
{
// Subtract offset from gyroscope measurement
gyroscope = FusionVectorSubtract(gyroscope, offset->gyroscopeOffset);
// Reset timer if gyroscope not stationary
if ((fabsf(gyroscope.axis.x) > THRESHOLD) || (fabsf(gyroscope.axis.y) > THRESHOLD) || (fabsf(gyroscope.axis.z) > THRESHOLD)) {
offset->timer = 0;
return gyroscope;
}
// Increment timer while gyroscope stationary
if (offset->timer < offset->timeout) {
offset->timer++;
return gyroscope;
}
// Adjust offset if timer has elapsed
offset->gyroscopeOffset =
FusionVectorAdd(offset->gyroscopeOffset, FusionVectorMultiplyScalar(gyroscope, offset->filterCoefficient));
return gyroscope;
}
//------------------------------------------------------------------------------
// End of file
+40
View File
@@ -0,0 +1,40 @@
/**
* @file FusionOffset.h
* @author Seb Madgwick
* @brief Gyroscope offset correction algorithm for run-time calibration of the
* gyroscope offset.
*/
#ifndef FUSION_OFFSET_H
#define FUSION_OFFSET_H
//------------------------------------------------------------------------------
// Includes
#include "FusionMath.h"
//------------------------------------------------------------------------------
// Definitions
/**
* @brief Gyroscope offset algorithm structure. Structure members are used
* internally and must not be accessed by the application.
*/
typedef struct {
float filterCoefficient;
unsigned int timeout;
unsigned int timer;
FusionVector gyroscopeOffset;
} FusionOffset;
//------------------------------------------------------------------------------
// Function declarations
void FusionOffsetInitialise(FusionOffset *const offset, const unsigned int sampleRate);
FusionVector FusionOffsetUpdate(FusionOffset *const offset, FusionVector gyroscope);
#endif
//------------------------------------------------------------------------------
// End of file
+6 -11
View File
@@ -14,7 +14,6 @@
* For more information, see: https://meshtastic.org/
*/
#include "power.h"
#include "BluetoothCommon.h"
#include "MessageStore.h"
#include "NodeDB.h"
#include "PowerFSM.h"
@@ -48,7 +47,7 @@
#include "concurrency/LockGuard.h"
#endif
#if defined(ARCH_STM32) && defined(BATTERY_PIN)
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
#include "stm32yyxx_ll_adc.h"
/* Analog read resolution */
@@ -431,7 +430,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
float scaled = 0;
battery_adcEnable();
#ifdef ARCH_STM32
#ifdef ARCH_STM32WL
// STM32 ADC with VREFINT runtime calibration
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
raw = analogRead(BATTERY_PIN);
@@ -608,7 +607,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
bool initial_read_done = false;
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
uint32_t last_read_time_ms = 0;
#ifdef ARCH_STM32
#ifdef ARCH_STM32WL
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
uint32_t Vref = 3300;
@@ -718,7 +717,7 @@ bool Power::analogInit()
#define BATTERY_SENSE_RESOLUTION_BITS 10
#endif
#ifdef ARCH_STM32
#ifdef ARCH_STM32WL
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
adc_oneshot_unit_init_cfg_t init_config = {
@@ -749,7 +748,7 @@ bool Power::analogInit()
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32)
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#endif
@@ -838,7 +837,7 @@ void Power::reboot()
}
LOG_DEBUG("final reboot!");
::reboot();
#elif defined(ARCH_STM32)
#elif defined(ARCH_STM32WL)
HAL_NVIC_SystemReset();
#else
rebootAtMsec = -1;
@@ -963,10 +962,6 @@ void Power::readPowerStatus()
lastLogTime = millis();
}
newStatus.notifyObservers(&powerStatus2);
// Mirror battery level to the BLE Battery Service (0x2A19); the platform layer clamps and dedupes.
if (hasBattery == OptTrue)
updateBatteryLevel(powerStatus2.getBatteryChargePercent());
#ifdef DEBUG_HEAP
if (lastheap != memGet.getFreeHeap()) {
// Use stack-allocated buffer to avoid heap allocations in monitoring code
-4
View File
@@ -219,11 +219,7 @@ static void darkEnter()
static void serialEnter()
{
LOG_POWERFSM("State: serialEnter");
#ifndef ARCH_NRF52
// nRF52 runs BLE on SoftDevice independently of USB serial — no need to disable it.
// (Same rationale as nbEnter() which already guards this with #ifdef ARCH_ESP32)
setBluetoothEnable(false);
#endif
if (screen) {
screen->setOn(true);
}
-89
View File
@@ -573,94 +573,5 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define USE_ETHERNET_DEFAULT 0
#endif
// -----------------------------------------------------------------------------
// MESHTASTIC_LOCKDOWN — runtime, client-toggleable hardening (nRF52 only)
//
// Lockdown/protect support is opt-in at build time. Builds that need it pass
// -DMESHTASTIC_ENABLE_LOCKDOWN=1. When enabled on nRF52 (CC310 hardware
// crypto), whether it is ACTIVE is decided entirely at runtime by
// EncryptedStorage::isLockdownActive()
// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
// that has never been provisioned — or that the operator disabled from the
// client app — behaves exactly like stock firmware: plaintext storage, no
// redaction, normal logging, normal display.
//
// The operator toggles lockdown from the client app:
// off -> on : provision a passphrase (AdminMessage.lockdown_auth). The
// firmware generates a DEK, encrypts the stored config, and
// authorizes the connection.
// on -> off : AdminMessage.lockdown_auth { disable=true } with the
// passphrase — decrypts storage back to plaintext and removes
// the DEK / token / monotonic-counter / backoff files, then
// reboots into normal mode. APPROTECT is the one thing that
// does NOT revert (see below).
//
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker. It gates the UI
// bits (lock screen, pairing-PIN handling). Flash-constrained nRF52 variants
// that genuinely cannot afford the ~tens-of-KB of crypto + access-control code
// may also opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
//
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — per-connection auth + redaction,
// gated at runtime on isLockdownActive()
// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest
// MESHTASTIC_ENABLE_APPROTECT — UICR APPROTECT capability. The actual
// one-way burn happens at runtime, only
// once provisioned, only on non-vulnerable
// silicon, and is STICKY: disabling
// lockdown does NOT (cannot) reverse it.
//
// DEBUG_MUTE is intentionally NOT coupled to lockdown — a capable-but-off
// device must log normally. Define DEBUG_MUTE separately for a silent build.
//
// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
// even when provisioned — for development so dev boards never lose SWD.
// -----------------------------------------------------------------------------
#if defined(ARCH_NRF52)
#ifndef MESHTASTIC_ENABLE_LOCKDOWN
#define MESHTASTIC_ENABLE_LOCKDOWN 0
#endif
#if !MESHTASTIC_ENABLE_LOCKDOWN
#undef MESHTASTIC_LOCKDOWN
#undef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
#undef MESHTASTIC_ENCRYPTED_STORAGE
#undef MESHTASTIC_ENABLE_APPROTECT
#ifndef MESHTASTIC_EXCLUDE_LOCKDOWN
#define MESHTASTIC_EXCLUDE_LOCKDOWN 1
#endif
#endif
#if MESHTASTIC_ENABLE_LOCKDOWN && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
#define MESHTASTIC_LOCKDOWN 1
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
#define MESHTASTIC_ENCRYPTED_STORAGE 1
#ifndef MESHTASTIC_LOCKDOWN_DEBUG
#define MESHTASTIC_ENABLE_APPROTECT 1
#endif
#endif
#endif
#ifdef MESHTASTIC_LOCKDOWN
// Per-boot uptime cap on unlocked sessions. 0 = unlimited (token-only
// enforcement, the existing behavior). When non-zero, every passphrase
// unlock (and every token-auto-unlock that inherits the value) arms a
// timer; on expiry the device lockNow()s and reboots into locked state.
// Bounds the total exposure window to bootsRemaining * this value if an
// attacker has physical possession but not the passphrase.
//
// Override at build time. Suggested:
// carry device: 3600 (1h sessions, periodic re-auth from phone)
// tower / infra node: 0 (default — relies on token TTLs only)
//
// A future LockdownAuth.max_session_seconds proto field will let the
// client set this per-token; until that lands the build-time value is
// the only source.
#ifndef MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS
#define MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS 0
#endif
#endif // MESHTASTIC_LOCKDOWN
#include "DebugConfiguration.h"
#include "RF95Configuration.h"
+5 -5
View File
@@ -37,15 +37,15 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
return firstOfOrNONE(12, types);
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
return firstOfOrNONE(11, types);
}
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
{
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
return firstOfOrNONE(2, types);
ScanI2C::DeviceType types[] = {MMC5983MA};
return firstOfOrNONE(1, types);
}
ScanI2C::FoundDevice ScanI2C::firstAQI() const
-2
View File
@@ -99,8 +99,6 @@ class ScanI2C
CW2015,
SCD30,
ADS1115,
IIS2MDCTR,
ISM330DHCX,
} DeviceType;
// typedef uint8_t DeviceAddress;
+2 -15
View File
@@ -8,7 +8,7 @@
#if defined(ARCH_PORTDUINO)
#include "linux/LinuxHardwareI2C.h"
#endif
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
#include "meshUtils.h" // vformat
#endif
@@ -584,9 +584,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
if (registerValue == 0x6A) {
type = LSM6DS3;
logFoundDevice("LSM6DS3", (uint8_t)addr.address);
} else if (registerValue == 0x6B) {
type = ISM330DHCX;
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
} else {
type = QMI8658;
logFoundDevice("QMI8658", (uint8_t)addr.address);
@@ -594,17 +591,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
SCAN_SIMPLE_CASE(QMC5883L_ADDR, QMC5883L, "QMC5883L", (uint8_t)addr.address)
case HMC5883L_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x4FU), 1); // get ID
if (registerValue == 0x40) {
type = IIS2MDCTR;
logFoundDevice("IIS2MDCTR", (uint8_t)addr.address);
break;
} else {
type = HMC5883L;
logFoundDevice("HMC5883L", (uint8_t)addr.address);
break;
}
SCAN_SIMPLE_CASE(HMC5883L_ADDR, HMC5883L, "HMC5883L", (uint8_t)addr.address)
#ifdef HAS_QMA6100P
SCAN_SIMPLE_CASE(QMA6100P_ADDR, QMA6100P, "QMA6100P", (uint8_t)addr.address)
#else
+15 -356
View File
@@ -17,10 +17,7 @@
#include "main.h" // pmu_found
#include "sleep.h"
#include "FSCommon.h"
#include "GPSUpdateScheduling.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "cas.h"
#include "ubx.h"
@@ -47,7 +44,7 @@ template <typename T, std::size_t N> std::size_t array_count(const T (&)[N])
#if defined(ARCH_NRF52)
Uart *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
HardwareSerial *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_RP2040)
SerialUART *GPS::_serial_gps = &GPS_SERIAL_PORT;
@@ -74,112 +71,6 @@ static struct uBloxGnssModelInfo {
#define GPS_SOL_EXPIRY_MS 5000 // in millis. give 1 second time to combine different sentences. NMEA Frequency isn't higher anyway
#define NMEA_MSG_GXGSA "GNGSA" // GSA message (GPGSA, GNGSA etc)
namespace
{
// Versioned on-disk record for persisted GPS probe results.
constexpr uint32_t GPS_PROBE_CACHE_MAGIC = 0x47504348UL; // "GPCH"
constexpr uint16_t GPS_PROBE_CACHE_VERSION = 1;
constexpr const char *GPS_PROBE_CACHE_FILE = "/prefs/gps_probe_cache.dat";
constexpr int MIN_PLAUSIBLE_GPS_YEAR = 2020;
constexpr int MAX_PLAUSIBLE_GPS_YEAR = 2100;
#ifdef TRACKER_T1000_E
constexpr uint32_t T1000_E_AIROHA_WAKE_MS = 1000;
constexpr uint32_t T1000_E_AIROHA_WAKE_INTERVAL_MS = 40;
#endif
struct GPSProbeCacheRecord {
uint32_t magic;
uint16_t version;
uint16_t reserved;
uint32_t baud;
uint8_t model;
};
bool isValidGnssModel(uint8_t model)
{
// Keep persisted values bounded to known enum range.
return model <= static_cast<uint8_t>(GNSS_MODEL_CM121);
}
bool isValidProbeBaud(uint32_t baud)
{
// Conservative sanity range for UART baud values.
return baud >= 1200 && baud <= 921600;
}
template <typename T> void wakeAirohaForActiveProbe(T *serialGps)
{
#ifdef TRACKER_T1000_E
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
digitalWrite(GPS_RTC_INT, HIGH);
delay(3);
digitalWrite(GPS_RTC_INT, LOW);
delay(50);
const uint32_t start = millis();
do {
serialGps->write("$PAIR382,1*2E\r\n");
delay(T1000_E_AIROHA_WAKE_INTERVAL_MS);
} while (Throttle::isWithinTimespanMs(start, T1000_E_AIROHA_WAKE_MS));
#elif defined(GNSS_AIROHA)
serialGps->write("$PAIR382,1*2E\r\n");
delay(20);
#else
(void)serialGps;
#endif
}
bool isPlausibleNmeaTime(const struct tm &t)
{
const int year = t.tm_year + 1900;
if (year < MIN_PLAUSIBLE_GPS_YEAR || year > MAX_PLAUSIBLE_GPS_YEAR) {
return false;
}
#ifdef BUILD_EPOCH
const int64_t candidate = static_cast<int64_t>(gm_mktime(&t));
const int64_t minEpoch = static_cast<int64_t>(BUILD_EPOCH);
const int64_t maxEpoch = minEpoch + static_cast<int64_t>(FORTY_YEARS);
return candidate >= minEpoch && candidate <= maxEpoch;
#else
return true;
#endif
}
template <typename T> bool sawNmeaSentenceAtBaud(T *serialGps, uint32_t timeoutMs)
{
// Lightweight passive check: look for at least one complete
// "$...,<field>\n" style NMEA sentence.
const uint32_t deadline = millis() + timeoutMs;
bool sawDollar = false;
bool sawComma = false;
while ((int32_t)(millis() - deadline) < 0) {
while (serialGps->available()) {
char c = static_cast<char>(serialGps->read());
if (c == '$') {
sawDollar = true;
sawComma = false;
continue;
}
if (c == ',') {
sawComma = true;
}
if (c == '\n' || c == '\r') {
if (sawDollar && sawComma) {
return true;
}
sawDollar = false;
sawComma = false;
}
}
delay(10);
}
return false;
}
} // namespace
// For logging
static const char *getGPSPowerStateString(GPSPowerState state)
{
@@ -601,201 +492,6 @@ static const int rareSerialSpeeds[3] = {4800, 57600, GPS_BAUDRATE};
#define GPS_PROBETRIES 2
#endif
bool GPS::loadProbeCache()
{
#ifdef FSCom
// Load the last known-good GPS model/baud pair so we can avoid a full probe
// sweep on every boot.
triedProbeCache = true; // Latch this boot's load attempt, even if no cache.
GPSProbeCacheRecord record = {};
size_t bytesRead = 0;
spiLock->lock();
auto file = FSCom.open(GPS_PROBE_CACHE_FILE, FILE_O_READ);
if (!file) {
spiLock->unlock();
return false;
}
bytesRead = file.read(reinterpret_cast<uint8_t *>(&record), sizeof(record));
file.close();
spiLock->unlock();
const bool headerValid = (bytesRead == sizeof(record)) && (record.magic == GPS_PROBE_CACHE_MAGIC) &&
(record.version == GPS_PROBE_CACHE_VERSION) && (record.reserved == 0U);
if (!headerValid || !isValidGnssModel(record.model) || !isValidProbeBaud(record.baud)) {
clearProbeCache(); // Drop corrupt/invalid cache so next boot can
// recover.
return false;
}
cachedProbeBaud = static_cast<int32_t>(record.baud);
cachedProbeModel = static_cast<GnssModel_t>(record.model);
hasProbeCache = true;
triedProbeCache = false;
LOG_INFO("Loaded cached GPS probe: baud=%u", record.baud);
return true;
#else
return false;
#endif
}
void GPS::clearProbeCache()
{
// Invalidate in-memory and on-disk cache so next boot is forced to do a
// full probe.
hasProbeCache = false;
triedProbeCache = true;
cachedProbeBaud = 0;
cachedProbeModel = GNSS_MODEL_UNKNOWN;
#ifdef FSCom
spiLock->lock();
if (FSCom.exists(GPS_PROBE_CACHE_FILE)) {
FSCom.remove(GPS_PROBE_CACHE_FILE);
}
spiLock->unlock();
#endif
}
bool GPS::saveProbeCache() const
{
#ifdef FSCom
if (gnssModel == GNSS_MODEL_UNKNOWN || !isValidGnssModel(static_cast<uint8_t>(gnssModel)) ||
!isValidProbeBaud(detectedBaud)) {
return false;
}
spiLock->lock();
FSCom.mkdir("/prefs");
spiLock->unlock();
GPSProbeCacheRecord record = {
GPS_PROBE_CACHE_MAGIC, GPS_PROBE_CACHE_VERSION, 0, static_cast<uint32_t>(detectedBaud), static_cast<uint8_t>(gnssModel),
};
auto file = SafeFile(GPS_PROBE_CACHE_FILE, true);
spiLock->lock();
const size_t written = file.write(reinterpret_cast<const uint8_t *>(&record), sizeof(record));
spiLock->unlock();
return (written == sizeof(record)) && file.close();
#else
return false;
#endif
}
bool GPS::verifyCachedProbePresence()
{
if (!hasProbeCache || cachedProbeModel == GNSS_MODEL_UNKNOWN || !isValidProbeBaud(cachedProbeBaud)) {
return false;
}
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
_serial_gps->end();
_serial_gps->begin(cachedProbeBaud);
#elif defined(ARCH_RP2040)
_serial_gps->end();
_serial_gps->setFIFOSize(256);
_serial_gps->begin(cachedProbeBaud);
#else
if (_serial_gps->baudRate() != cachedProbeBaud) {
LOG_DEBUG("Set GPS Baud to %i (cached verify)", cachedProbeBaud);
_serial_gps->updateBaudRate(cachedProbeBaud);
}
#endif
// Before trusting cached model/baud, require either active model-specific
// response or passive NMEA flow.
clearBuffer();
bool present = false;
// Model-specific "active ping" checks to avoid false stale decisions on
// modules that start streaming late.
const char *cachedProbeModelName = "UNKNOWN";
switch (cachedProbeModel) {
case GNSS_MODEL_MTK:
cachedProbeModelName = "L76K/MTK";
_serial_gps->write("$PCAS06,0*1B\r\n");
present = (getACK("$GPTXT,01,01,02,SW=", 700) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_MTK_L76B:
cachedProbeModelName = "L76B";
case GNSS_MODEL_MTK_PA1010D:
if (cachedProbeModel == GNSS_MODEL_MTK_PA1010D)
cachedProbeModelName = "PA1010D";
case GNSS_MODEL_MTK_PA1616S:
if (cachedProbeModel == GNSS_MODEL_MTK_PA1616S)
cachedProbeModelName = "PA1616S";
case GNSS_MODEL_LS20031:
if (cachedProbeModel == GNSS_MODEL_LS20031)
cachedProbeModelName = "LS20031";
_serial_gps->write("$PMTK605*31\r\n");
present = (getACK("$PMTK705", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_AG3335:
cachedProbeModelName = "AG3335";
case GNSS_MODEL_AG3352:
if (cachedProbeModel == GNSS_MODEL_AG3352)
cachedProbeModelName = "AG3352";
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR021*39\r\n");
present = (getACK("$PAIR021,", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_ATGM336H:
cachedProbeModelName = "ATGM336H";
_serial_gps->write("$PCAS06,1*1A\r\n");
present = (getACK("$GPTXT,01,01,02,HW=ATGM", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_UC6580:
cachedProbeModelName = "UC6580/UM600";
_serial_gps->write("$PDTINFO\r\n");
present = (getACK("UC6580", 900) == GNSS_RESPONSE_OK) || (getACK("UM600", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_CM121:
cachedProbeModelName = "CM121";
_serial_gps->write("$PDTINFO\r\n");
present = (getACK("CM121", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_UBLOX6:
case GNSS_MODEL_UBLOX7:
case GNSS_MODEL_UBLOX8:
case GNSS_MODEL_UBLOX9:
case GNSS_MODEL_UBLOX10: {
if (cachedProbeModel == GNSS_MODEL_UBLOX6)
cachedProbeModelName = "U-blox 6";
else if (cachedProbeModel == GNSS_MODEL_UBLOX7)
cachedProbeModelName = "U-blox 7";
else if (cachedProbeModel == GNSS_MODEL_UBLOX8)
cachedProbeModelName = "U-blox 8";
else if (cachedProbeModel == GNSS_MODEL_UBLOX9)
cachedProbeModelName = "U-blox 9";
else if (cachedProbeModel == GNSS_MODEL_UBLOX10)
cachedProbeModelName = "U-blox 10";
uint8_t cfg_rate[] = {0xB5, 0x62, 0x06, 0x08, 0x00, 0x00, 0x00, 0x00};
UBXChecksum(cfg_rate, sizeof(cfg_rate));
_serial_gps->write(cfg_rate, sizeof(cfg_rate));
present = (getACK(0x06, 0x08, 900) != GNSS_RESPONSE_NONE);
break;
}
default:
break;
}
if (!present) {
// Some modules may not respond to probes while still streaming NMEA, so
// allow a passive fallback check.
present = sawNmeaSentenceAtBaud(_serial_gps, 3000);
}
if (!present) {
LOG_WARN("Cached GPS probe is stale (%s @ %d), clearing cache", cachedProbeModelName, cachedProbeBaud);
clearProbeCache();
return false;
}
detectedBaud = cachedProbeBaud;
gnssModel = cachedProbeModel;
LOG_INFO("Using cached GPS probe: %s @ %d", cachedProbeModelName, detectedBaud);
return true;
}
/**
* @brief Setup the GPS based on the model detected.
* We detect the GPS by cycling through a set of baud rates, first common then rare.
@@ -808,39 +504,24 @@ bool GPS::setup()
if (!didSerialInit) {
int msglen = 0;
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
if (!hasProbeCache && !triedProbeCache) {
(void)loadProbeCache();
}
if (hasProbeCache && !triedProbeCache) {
triedProbeCache = true;
if (!verifyCachedProbePresence()) {
currentStep = 0;
speedSelect = 0;
probeTries = 0;
}
}
if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries < GPS_PROBETRIES) {
// No usable cache: walk common baud rates first.
if (probeTries < GPS_PROBETRIES) {
gnssModel = probe(serialSpeeds[speedSelect]);
if (gnssModel != GNSS_MODEL_UNKNOWN) {
detectedBaud = serialSpeeds[speedSelect];
} else if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
speedSelect = 0;
++probeTries;
if (gnssModel == GNSS_MODEL_UNKNOWN) {
if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
speedSelect = 0;
++probeTries;
}
}
}
// Rare Serial Speeds
#ifndef CONFIG_IDF_TARGET_ESP32C6
else if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries == GPS_PROBETRIES) {
// Then try less common baud rates before giving up.
if (probeTries == GPS_PROBETRIES) {
gnssModel = probe(rareSerialSpeeds[speedSelect]);
if (gnssModel != GNSS_MODEL_UNKNOWN) {
detectedBaud = rareSerialSpeeds[speedSelect];
} else if (currentStep == 0 && ++speedSelect == array_count(rareSerialSpeeds)) {
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
return true;
if (gnssModel == GNSS_MODEL_UNKNOWN) {
if (currentStep == 0 && ++speedSelect == array_count(rareSerialSpeeds)) {
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
return true;
}
}
}
#endif
@@ -848,7 +529,6 @@ bool GPS::setup()
if (gnssModel != GNSS_MODEL_UNKNOWN) {
setConnected();
(void)saveProbeCache();
} else {
return false;
}
@@ -1164,15 +844,6 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
break;
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
clearBuffer();
#ifdef TRACKER_T1000_E
pinMode(GPS_VRTC_EN, OUTPUT);
digitalWrite(GPS_VRTC_EN, HIGH);
pinMode(GPS_SLEEP_INT, OUTPUT);
digitalWrite(GPS_SLEEP_INT, HIGH);
pinMode(GPS_RTC_INT, OUTPUT);
digitalWrite(GPS_RTC_INT, LOW);
pinMode(GPS_RESETB_OUT, INPUT_PULLUP);
#endif
powerMon->setState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinEN(true); // Power (EN pin): on
setPowerPMU(true); // Power (PMU): on
@@ -1431,11 +1102,6 @@ int32_t GPS::runOnce()
if (!setup())
return currentDelay; // Setup failed, re-run in two seconds
if (gnssModel == GNSS_MODEL_UNKNOWN) {
LOG_WARN("GPS not detected; marked not present for this boot");
return disable();
}
// We have now loaded our saved preferences from flash
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
return disable();
@@ -1611,7 +1277,7 @@ GnssModel_t GPS::probe(int serialSpeed)
switch (currentStep) {
case 0: {
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
_serial_gps->end();
_serial_gps->begin(serialSpeed);
#elif defined(ARCH_RP2040)
@@ -1632,9 +1298,6 @@ GnssModel_t GPS::probe(int serialSpeed)
digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); // assert for 10ms
delay(10);
digitalWrite(PIN_GPS_RESET, !GPS_RESET_MODE);
#ifdef TRACKER_T1000_E
delay(100);
#endif
// attempt to detect the chip based on boot messages
std::vector<ChipInfo> passive_detect = {
@@ -1687,7 +1350,6 @@ GnssModel_t GPS::probe(int serialSpeed)
}
case 3: {
/* Airoha (Mediatek) AG3335A/M/S, A3352Q, Quectel L89 2.0, SimCom SIM65M */
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF to reduce volume
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF to reduce volume
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
@@ -1972,7 +1634,7 @@ std::unique_ptr<GPS> GPS::createGps()
#elif defined(ARCH_NRF52)
_serial_gps->setPins(new_gps->rx_gpio, new_gps->tx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
#elif defined(ARCH_STM32)
#elif defined(ARCH_STM32WL)
_serial_gps->setTx(new_gps->tx_gpio);
_serial_gps->setRx(new_gps->rx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
@@ -2019,9 +1681,6 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s
t.tm_year = d.year() - 1900;
t.tm_isdst = false;
if (t.tm_mon > -1) {
if (!isPlausibleNmeaTime(t)) {
return false;
}
if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultSuccess) {
LOG_DEBUG("NMEA GPS time set %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour,
t.tm_min, t.tm_sec, ti.age());
-15
View File
@@ -155,19 +155,8 @@ 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
@@ -189,10 +178,6 @@ class GPS : private concurrency::OSThread
uint8_t speedSelect = 0;
uint8_t probeTries = 0;
// Cache file is successfully loaded.
bool hasProbeCache = false;
// Ensures cached probe is attempted once per boot.
bool triedProbeCache = false;
/**
* hasValidLocation - indicates that the position variables contain a complete
+211
View File
@@ -0,0 +1,211 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./BaseUIEInkDisplay.h"
#include "configuration.h"
#include "main.h"
using namespace NicheGraphics;
BaseUIEInkDisplay::BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation) : driver(driver), rotation(rotation & 0x3)
{
this->geometry = GEOMETRY_RAWMODE;
// BaseUI draws in UI orientation. Physical panel dimensions are swapped for 90°/270°.
const bool swap = (this->rotation == 1) || (this->rotation == 3);
this->displayWidth = swap ? driver->height : driver->width;
this->displayHeight = swap ? driver->width : driver->height;
uint16_t shortSide = min(displayWidth, displayHeight);
uint16_t longSide = max(displayWidth, displayHeight);
if (shortSide % 8 != 0)
shortSide = (shortSide | 7) + 1;
this->displayBufferSize = longSide * (shortSide / 8);
// Panel-native row-major buffer
panelRowBytes = ((driver->width - 1) / 8) + 1;
panelBufferSize = panelRowBytes * driver->height;
panelBuffer = new uint8_t[panelBufferSize];
memset(panelBuffer, 0xFF, panelBufferSize); // All white
}
BaseUIEInkDisplay::~BaseUIEInkDisplay()
{
delete[] panelBuffer;
}
bool BaseUIEInkDisplay::connect()
{
LOG_INFO("Init BaseUI E-Ink (%u x %u, rot %u)", driver->width, driver->height, rotation);
return true;
}
void BaseUIEInkDisplay::addFrameFlag(frameFlagTypes flag)
{
frameFlags = (frameFlagTypes)(frameFlags | flag);
}
void BaseUIEInkDisplay::setDisplayResilience(uint8_t fastPerFull, float stressMultiplier)
{
this->fastPerFull = (fastPerFull == 0) ? 1 : fastPerFull;
this->stressMultiplier = stressMultiplier;
}
void BaseUIEInkDisplay::joinAsyncRefresh()
{
if (driver->busy())
driver->await();
}
// OLEDDisplayUi tick path. Honours rate-limit unless flags demand otherwise.
void BaseUIEInkDisplay::display()
{
const bool demandFast = frameFlags & DEMAND_FAST;
const bool cosmetic = frameFlags & COSMETIC;
const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
if (!demandFast && !cosmetic && !unlimitedFast) {
if (!forceDisplay(lastDrawMsec == 0 ? 0 : 1000))
return;
return;
}
forceDisplay(0);
}
// Keyframe path. Returns true if a frame was pushed (sets lastDrawMsec).
bool BaseUIEInkDisplay::forceDisplay(uint32_t msecLimit)
{
const uint32_t now = millis();
if (lastDrawMsec != 0 && (now - lastDrawMsec) < msecLimit)
return false;
const bool blocking = frameFlags & BLOCKING;
Drivers::EInk::UpdateTypes type = decide();
// Don't pile frames on top of a running update - wait it out.
if (driver->busy())
driver->await();
const bool pushed = commit(type, blocking);
if (pushed)
lastDrawMsec = now;
// Reset flags for next frame
frameFlags = BACKGROUND;
return pushed;
}
bool BaseUIEInkDisplay::commit(Drivers::EInk::UpdateTypes type, bool blocking)
{
uint32_t hash = repack();
// Skip if frame unchanged. Exception: caller explicitly wants a refresh (COSMETIC or FULL).
if (hash == lastHash && type != Drivers::EInk::UpdateTypes::FULL && lastDrawMsec != 0)
return false;
lastHash = hash;
// Fall back to FULL on panels that don't advertise FAST support.
if (type == Drivers::EInk::UpdateTypes::FAST && !driver->supports(Drivers::EInk::UpdateTypes::FAST))
type = Drivers::EInk::UpdateTypes::FULL;
driver->update(panelBuffer, type);
if (blocking)
driver->await();
return true;
}
Drivers::EInk::UpdateTypes BaseUIEInkDisplay::decide()
{
typedef Drivers::EInk::UpdateTypes UT;
const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
// Explicit flag wins outright
if (frameFlags & COSMETIC) {
fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
return UT::FULL;
}
if (frameFlags & DEMAND_FAST) {
if (!unlimitedFast) {
fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
}
return UT::FAST;
}
const bool explicitFast = frameFlags & RESPONSIVE;
if (explicitFast || unlimitedFast) {
if (!unlimitedFast) {
fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
}
return UT::FAST;
}
// BACKGROUND / unspecified: let debt decide
if (fullRefreshDebt >= 1.0f) {
fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
return UT::FULL;
}
fullRefreshDebt += 1.0f / fastPerFull;
return UT::FAST;
}
uint32_t BaseUIEInkDisplay::repack()
{
memset(panelBuffer, 0xFF, panelBufferSize); // start all-white
const uint16_t pw = driver->width;
const uint16_t ph = driver->height;
// OLEDDisplay buffer: byte = buffer[x + (y/8) * displayWidth]; bit = 1 << (y & 7); 1 = black
// Niche buffer: byte = (y * panelRowBytes) + (x/8); bit = 1 << (7 - x%8); 1 = white
for (uint16_t oy = 0; oy < displayHeight; oy++) {
for (uint16_t ox = 0; ox < displayWidth; ox++) {
const uint8_t b = buffer[ox + (oy / 8) * displayWidth];
const bool isBlack = b & (1 << (oy & 7));
uint16_t px, py;
switch (rotation) {
case 1: // 90° CW: OLED (ox,oy) → panel (pw-1-oy, ox)
px = pw - 1 - oy;
py = ox;
break;
case 2: // 180°
px = pw - 1 - ox;
py = ph - 1 - oy;
break;
case 3: // 270° CW
px = oy;
py = ph - 1 - ox;
break;
case 0:
default:
px = ox;
py = oy;
break;
}
if (px >= pw || py >= ph)
continue;
const uint32_t byteNum = (py * panelRowBytes) + (px / 8);
const uint8_t bitNum = 7 - (px % 8);
if (isBlack)
panelBuffer[byteNum] &= ~(1 << bitNum);
else
panelBuffer[byteNum] |= (1 << bitNum);
}
}
// FNV-1a
uint32_t h = 2166136261u;
for (uint32_t i = 0; i < panelBufferSize; i++) {
h ^= panelBuffer[i];
h *= 16777619u;
}
return h;
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+98
View File
@@ -0,0 +1,98 @@
/*
OLEDDisplay adapter that routes BaseUI pixel output to a NicheGraphics::Drivers::EInk driver.
One adapter serves all E-Ink variants: the panel driver and orientation are injected at construction,
and FULL/FAST selection is made by the shared DisplayHealth model (same as InkHUD).
Replaces the per-board branching in EInkDisplay2 / EInkDynamicDisplay / EInkParallelDisplay.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "graphics/eink/Drivers/EInk.h"
#include <OLEDDisplay.h>
namespace NicheGraphics
{
class BaseUIEInkDisplay : public OLEDDisplay
{
public:
// Flags Screen.cpp sets via EINK_ADD_FRAMEFLAG before triggering a draw.
// Bits are combined; decided at render time.
enum frameFlagTypes : uint8_t {
BACKGROUND = (1 << 0), // Regular OLEDDisplayUi tick - no urgency, UNSPECIFIED
RESPONSIVE = (1 << 1), // User-driven refresh - prefer FAST
COSMETIC = (1 << 2), // Clean splash / wake-from-sleep - force FULL
DEMAND_FAST = (1 << 3), // Menu interaction - force FAST
BLOCKING = (1 << 4), // Wait for update to finish before returning
UNLIMITED_FAST = (1 << 5), // Suppress health-driven FULL promotion (typing modes)
};
BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation);
~BaseUIEInkDisplay() override;
// OLEDDisplay overrides
bool connect() override;
void display() override;
void sendCommand(uint8_t com) override { (void)com; }
int getBufferOffset(void) override { return 0; }
// BaseUI public API (same shape as the old EInkDynamicDisplay)
bool forceDisplay(uint32_t msecLimit = 1000);
void addFrameFlag(frameFlagTypes flag);
void joinAsyncRefresh();
void enableUnlimitedFastMode() { addFrameFlag(UNLIMITED_FAST); }
void disableUnlimitedFastMode() { frameFlags = (frameFlagTypes)(frameFlags & ~UNLIMITED_FAST); }
// Tuning, called once per panel profile
void setDisplayResilience(uint8_t fastPerFull, float stressMultiplier = 2.0f);
// Exposed so Screen.cpp / variants can read the rotation passed in at construction
uint8_t getRotation() const { return rotation; }
private:
// Perform an update now, unconditionally. Returns true if a frame was pushed to the driver.
bool commit(Drivers::EInk::UpdateTypes type, bool blocking);
// Convert OLEDDisplay's column-major buffer into the panel's row-major MSB-left buffer.
// Applies rotation. Returns the hash of the panel buffer for frame-skip comparison.
uint32_t repack();
// Decide FULL vs FAST based on current frame flags + accumulated debt.
Drivers::EInk::UpdateTypes decide();
Drivers::EInk *driver = nullptr;
uint8_t rotation = 0; // 0=0°, 1=90°CW, 2=180°, 3=270°CW
uint8_t *panelBuffer = nullptr;
uint32_t panelBufferSize = 0;
uint16_t panelRowBytes = 0;
frameFlagTypes frameFlags = BACKGROUND;
uint32_t lastDrawMsec = 0;
uint32_t lastHash = 0;
// DisplayHealth-style debt tracking
float fullRefreshDebt = 0.0f;
uint8_t fastPerFull = 7;
float stressMultiplier = 2.0f;
};
} // namespace NicheGraphics
// Compat macros used throughout Screen.cpp - route straight to the adapter.
#define EINK_ADD_FRAMEFLAG(display, flag) \
static_cast<NicheGraphics::BaseUIEInkDisplay *>(display)->addFrameFlag(NicheGraphics::BaseUIEInkDisplay::flag)
#define EINK_JOIN_ASYNCREFRESH(display) static_cast<NicheGraphics::BaseUIEInkDisplay *>(display)->joinAsyncRefresh()
#else // !MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#define EINK_ADD_FRAMEFLAG(display, flag)
#define EINK_JOIN_ASYNCREFRESH(display)
#endif
-298
View File
@@ -1,298 +0,0 @@
#include "configuration.h"
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
#include "EInkDisplay2.h"
#include "SPILock.h"
#include "main.h"
#include <SPI.h>
#ifdef GXEPD2_DRIVER_0
#include "einkDetect.h"
#endif
/*
The macros EINK_DISPLAY_MODEL, EINK_WIDTH, and EINK_HEIGHT are defined as build_flags in a variant's platformio.ini
Previously, these macros were defined at the top of this file.
For archival reasons, note that the following configurations had also been tested during this period:
* ifdef RAK4631
- 4.2 inch
EINK_DISPLAY_MODEL: GxEPD2_420_M01
EINK_WIDTH: 300
EINK_WIDTH: 400
- 2.9 inch
EINK_DISPLAY_MODEL: GxEPD2_290_T5D
EINK_WIDTH: 296
EINK_HEIGHT: 128
- 1.54 inch
EINK_DISPLAY_MODEL: GxEPD2_154_M09
EINK_WIDTH: 200
EINK_HEIGHT: 200
*/
// Constructor
EInkDisplay::EInkDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
{
// Set dimensions in OLEDDisplay base class
this->geometry = GEOMETRY_RAWMODE;
this->displayWidth = EINK_WIDTH;
this->displayHeight = EINK_HEIGHT;
// Round shortest side up to nearest byte, to prevent truncation causing an undersized buffer
uint16_t shortSide = min(EINK_WIDTH, EINK_HEIGHT);
uint16_t longSide = max(EINK_WIDTH, EINK_HEIGHT);
if (shortSide % 8 != 0)
shortSide = (shortSide | 7) + 1;
this->displayBufferSize = longSide * (shortSide / 8);
}
/**
* Force a display update if we haven't drawn within the specified msecLimit
*/
bool EInkDisplay::forceDisplay(uint32_t msecLimit)
{
// No need to grab this lock because we are on our own SPI bus
// concurrency::LockGuard g(spiLock);
uint32_t now = millis();
uint32_t sinceLast = now - lastDrawMsec;
if (adafruitDisplay && (sinceLast > msecLimit || lastDrawMsec == 0))
lastDrawMsec = now;
else
return false;
// FIXME - only draw bits have changed (use backbuf similar to the other displays)
const bool flipped = config.display.flip_screen;
// HACK for L1 EInk
#if defined(SEEED_WIO_TRACKER_L1_EINK)
// For SEEED_WIO_TRACKER_L1_EINK, setRotation(3) is correct but mirrored; flip both axes
for (uint32_t y = 0; y < displayHeight; y++) {
for (uint32_t x = 0; x < displayWidth; x++) {
auto b = buffer[x + (y / 8) * displayWidth];
auto isset = b & (1 << (y & 7));
adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE);
}
}
#else
for (uint32_t y = 0; y < displayHeight; y++) {
for (uint32_t x = 0; x < displayWidth; x++) {
auto b = buffer[x + (y / 8) * displayWidth];
auto isset = b & (1 << (y & 7));
if (flipped)
adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE);
else
adafruitDisplay->drawPixel(x, y, isset ? GxEPD_BLACK : GxEPD_WHITE);
}
}
#endif
// Trigger the refresh in GxEPD2
LOG_DEBUG("Update E-Paper");
adafruitDisplay->nextPage();
// End the update process
endUpdate();
LOG_DEBUG("done");
return true;
}
// End the update process - virtual method, overridden in derived class
void EInkDisplay::endUpdate()
{
#ifndef EINK_NOT_HIBERNATE
// By default, power off the E-Ink display hardware and enter hibernate().
// Boards/panels that define EINK_NOT_HIBERNATE intentionally skip this step.
// Skipping hibernate() can help avoid panel-specific wake/refresh or ghosting issues,
// but it typically trades lower power savings for that compatibility.
adafruitDisplay->hibernate();
#endif
}
// Write the buffer to the display memory
void EInkDisplay::display(void)
{
// We don't allow regular 'dumb' display() calls to draw on eink until we've shown
// at least one forceDisplay() keyframe. This prevents flashing when we should the critical
// bootscreen (that we want to look nice)
if (lastDrawMsec) {
forceDisplay(slowUpdateMsec); // Show the first screen a few seconds after boot, then slower
}
}
// Send a command to the display (low level function)
void EInkDisplay::sendCommand(uint8_t com)
{
(void)com;
// Drop all commands to device (we just update the buffer)
}
void EInkDisplay::setDetected(uint8_t detected)
{
(void)detected;
}
// Connect to the display - variant specific
bool EInkDisplay::connect()
{
LOG_INFO("Do EInk init");
#ifdef PIN_EINK_EN
// backlight power, HIGH is backlight on, LOW is off
pinMode(PIN_EINK_EN, OUTPUT);
#ifdef ELECROW_ThinkNode_M1
// ThinkNode M1 has a hardware dimmable backlight. Start enabled
digitalWrite(PIN_EINK_EN, HIGH);
#elif defined(MINI_EPAPER_S3)
// T-Mini Epaper S3 requires panel power rail enabled before SPI transfer.
digitalWrite(PIN_EINK_EN, HIGH);
delay(10);
#else
digitalWrite(PIN_EINK_EN, LOW);
#endif
#endif
#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init();
#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE)
adafruitDisplay->setRotation(4);
#else
adafruitDisplay->setRotation(3);
#endif
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
}
#elif defined(ELECROW_ThinkNode_M5)
{
// Start HSPI
hspi = new SPIClass(HSPI);
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init();
adafruitDisplay->setRotation(4);
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
}
#elif defined(MESHLINK)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init();
adafruitDisplay->setRotation(3);
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
}
#elif defined(RAK4630) || defined(MAKERPYTHON)
{
if (eink_found) {
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 10, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
// RAK14000 2.13 inch b/w 250x122 does actually now support fast refresh
adafruitDisplay->setRotation(3);
// Fast refresh support for 1.54, 2.13 RAK14000 b/w , 2.9 and 4.2
// adafruitDisplay->setRotation(1);
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
} else {
(void)adafruitDisplay;
}
}
#elif defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || \
defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || \
defined(MINI_EPAPER_S3)
{
// Start HSPI
hspi = new SPIClass(HSPI);
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
// VExt already enabled in setup()
// RTC GPIO hold disabled in setup()
// Create GxEPD2 objects
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
// Init GxEPD2
adafruitDisplay->init();
#if defined(MINI_EPAPER_S3)
adafruitDisplay->setRotation(3);
#else
adafruitDisplay->setRotation(3);
#if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER)
adafruitDisplay->setRotation(0);
#endif
#endif
}
#elif defined(PCA10059) || defined(ME25LS01)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 40, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
adafruitDisplay->setRotation(0);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
}
#elif defined(M5_COREINK) || defined(T_DECK_PRO)
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
adafruitDisplay->setRotation(0);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
#elif defined(my) || defined(ESP32_S3_PICO)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
adafruitDisplay->setRotation(1);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
}
#elif defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK)
{
spi1 = &SPI1;
spi1->begin();
// VExt already enabled in setup()
// RTC GPIO hold disabled in setup()
// Create GxEPD2 objects
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
// Init GxEPD2
adafruitDisplay->init();
adafruitDisplay->setRotation(3);
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
}
#elif defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_VISION_MASTER_E213)
// Detect display model, before starting SPI
EInkDetectionResult displayModel = detectEInk();
// Start HSPI
hspi = new SPIClass(HSPI);
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
// Create GxEPD2 object
adafruitDisplay = new GxEPD2_Multi<GXEPD2_DRIVER_0, GXEPD2_DRIVER_1>((uint8_t)displayModel, PIN_EINK_CS, PIN_EINK_DC,
PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
// Init GxEPD2
adafruitDisplay->init();
adafruitDisplay->setRotation(3);
#endif
return true;
}
#endif
-106
View File
@@ -1,106 +0,0 @@
#pragma once
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
#include "GxEPD2_BW.h"
#include <OLEDDisplay.h>
#ifdef GXEPD2_DRIVER_0 // If variant has multiple possible display models
#include "GxEPD2Multi.h"
#endif
// Limit how often we push a full E-Ink refresh. T-Deck Pro needs faster updates for typing.
#ifndef EINK_FORCE_DISPLAY_THROTTLE_MS
#if defined(T_DECK_PRO)
#define EINK_FORCE_DISPLAY_THROTTLE_MS 200
#else
#define EINK_FORCE_DISPLAY_THROTTLE_MS 1000
#endif
#endif
/**
* An adapter class that allows using the GxEPD2 library as if it was an OLEDDisplay implementation.
*
* Note: EInkDynamicDisplay derives from this class.
*
* Remaining TODO:
* optimize display() to only draw changed pixels (see other OLED subclasses for examples)
* implement displayOn/displayOff to turn off the TFT device (and backlight)
* Use the fast NRF52 SPI API rather than the slow standard arduino version
*
* turn radio back on - currently with both on spi bus is fucked? or are we leaving chip select asserted?
* Suggestion: perhaps similar to HELTEC_WIRELESS_PAPER issue, which resolved with rtc_gpio_hold_dis()
*/
class EInkDisplay : public OLEDDisplay
{
/// How often should we update the display
/// thereafter we do once per 5 minutes
uint32_t slowUpdateMsec = 5 * 60 * 1000;
public:
/* constructor
FIXME - the parameters are not used, just a temporary hack to keep working like the old displays
*/
EInkDisplay(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C);
// Write the buffer to the display memory (for eink we only do this occasionally)
virtual void display(void) override;
/**
* Force a display update if we haven't drawn within the specified msecLimit
*
* @return true if we did draw the screen
*/
virtual bool forceDisplay(uint32_t msecLimit = EINK_FORCE_DISPLAY_THROTTLE_MS);
/**
* Run any code needed to complete an update, after the physical refresh has completed.
* Split from forceDisplay(), to enable async refresh in derived EInkDynamicDisplay class.
*
*/
virtual void endUpdate();
/**
* shim to make the abstraction happy
*
*/
void setDetected(uint8_t detected);
protected:
// the header size of the buffer used, e.g. for the SPI command header
virtual int getBufferOffset(void) override { return 0; }
// Send a command to the display (low level function)
virtual void sendCommand(uint8_t com) override;
// Connect to the display
virtual bool connect() override;
#ifdef GXEPD2_DRIVER_0
// AdafruitGFX display object - wrapper for multiple drivers
// Allows runtime detection of multiple displays
// Avoid this situation if possible!
GxEPD2_Multi<GXEPD2_DRIVER_0, GXEPD2_DRIVER_1> *adafruitDisplay = NULL;
#else
// AdafruitGFX display object (for single display model) - instantiated in connect(), variant specific
GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT> *adafruitDisplay = NULL;
#endif
// If display uses HSPI
#if defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E213) || \
defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || defined(CROWPANEL_ESP32S3_5_EPAPER) || \
defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) || \
defined(MINI_EPAPER_S3)
SPIClass *hspi = NULL;
#endif
#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK)
SPIClass *spi1 = NULL;
#endif
private:
// FIXME quick hack to limit drawing to a very slow rate
uint32_t lastDrawMsec = 0;
};
#endif
-564
View File
@@ -1,564 +0,0 @@
#include "Throttle.h"
#include "configuration.h"
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
#include "EInkDynamicDisplay.h"
// Constructor
EInkDynamicDisplay::EInkDynamicDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
: EInkDisplay(address, sda, scl, geometry, i2cBus), NotifiedWorkerThread("EInkDynamicDisplay")
{
// If tracking ghost pixels, grab memory
#ifdef EINK_LIMIT_GHOSTING_PX
dirtyPixels = std::unique_ptr<uint8_t[]>(new uint8_t[EInkDisplay::displayBufferSize]()); // Init with zeros
#endif
}
// Destructor
EInkDynamicDisplay::~EInkDynamicDisplay()
{
// If we were tracking ghost pixels, free the memory
#ifdef EINK_LIMIT_GHOSTING_PX
dirtyPixels = nullptr;
#endif
}
// Screen requests a BACKGROUND frame
void EInkDynamicDisplay::display()
{
addFrameFlag(BACKGROUND);
update();
}
// Screen requests a RESPONSIVE frame
bool EInkDynamicDisplay::forceDisplay(uint32_t msecLimit)
{
addFrameFlag(RESPONSIVE);
return update(); // (Unutilized) Base class promises to return true if update ran
}
// Add flag for the next frame
void EInkDynamicDisplay::addFrameFlag(frameFlagTypes flag)
{
// OR the new flag into the existing flags
this->frameFlags = (frameFlagTypes)(this->frameFlags | flag);
}
// GxEPD2 code to set fast refresh
void EInkDynamicDisplay::configForFastRefresh()
{
// Variant-specific code can go here
#if defined(PRIVATE_HW)
#else
// Otherwise:
adafruitDisplay->setPartialWindow(0, 0, adafruitDisplay->width(), adafruitDisplay->height());
#endif
}
// GxEPD2 code to set full refresh
void EInkDynamicDisplay::configForFullRefresh()
{
// Variant-specific code can go here
#if defined(PRIVATE_HW)
#else
// Otherwise:
adafruitDisplay->setFullWindow();
#endif
}
// Run any relevant GxEPD2 code, so next update will use correct refresh type
void EInkDynamicDisplay::applyRefreshMode()
{
// Change from FULL to FAST
if (currentConfig == FULL && refresh == FAST) {
configForFastRefresh();
currentConfig = FAST;
}
// Change from FAST back to FULL
else if (currentConfig == FAST && refresh == FULL) {
configForFullRefresh();
currentConfig = FULL;
}
}
// Update fastRefreshCount
void EInkDynamicDisplay::adjustRefreshCounters()
{
if (refresh == FAST)
fastRefreshCount++;
else if (refresh == FULL)
fastRefreshCount = 0;
}
// Trigger the display update by calling base class
bool EInkDynamicDisplay::update()
{
// Determine the refresh mode to use, and start the update
bool refreshApproved = determineMode();
if (refreshApproved) {
EInkDisplay::forceDisplay(0); // Bypass base class' own rate-limiting system
storeAndReset(); // Store the result of this loop for next time. Note: call *before* endOrDetach()
endOrDetach(); // endUpdate() right now, or set the async refresh flag (if FULL and HAS_EINK_ASYNCFULL)
} else
storeAndReset(); // No update, no post-update code, just store the results
return refreshApproved; // (Unutilized) Base class promises to return true if update ran
}
// Figure out who runs the post-update code
void EInkDynamicDisplay::endOrDetach()
{
// If the GxEPD2 version reports that it has the async modifications
#ifdef HAS_EINK_ASYNCFULL
if (previousRefresh == FULL) {
asyncRefreshRunning = true; // Set the flag - checked in determineMode(); cleared by onNotify()
if (previousFrameFlags & BLOCKING)
awaitRefresh();
else {
// Async begins
LOG_DEBUG("Async full-refresh begins (drop frames)");
notifyLater(intervalPollAsyncRefresh, DUE_POLL_ASYNCREFRESH, true); // Hand-off to NotifiedWorkerThread
}
}
// Fast Refresh
else if (previousRefresh == FAST)
EInkDisplay::endUpdate(); // Still block while updating, but EInkDisplay needs us to call endUpdate() ourselves.
// Fallback - If using an unmodified version of GxEPD2 for some reason
#else
if (previousRefresh == FULL || previousRefresh == FAST) { // If refresh wasn't skipped (on unspecified..)
LOG_WARN(
"GxEPD2 version has not been modified to support async refresh; using fallback behavior. Please update lib_deps in "
"variant's platformio.ini file");
EInkDisplay::endUpdate();
}
#endif
}
// Assess situation, pick a refresh type
bool EInkDynamicDisplay::determineMode()
{
checkInitialized();
checkForPromotion();
#if defined(HAS_EINK_ASYNCFULL)
checkBusyAsyncRefresh();
#endif
checkRateLimiting();
// If too soon for a new frame, or display busy, abort early
if (refresh == SKIPPED)
return false; // No refresh
// -- New frame is due --
resetRateLimiting(); // Once determineMode() ends, will have to wait again
hashImage(); // Generate here, so we can still copy it to previousImageHash, even if we skip the comparison check
LOG_DEBUG("determineMode(): "); // Begin log entry
// Once mode determined, any remaining checks will bypass
checkCosmetic();
checkDemandingFast();
checkFrameMatchesPrevious();
checkConsecutiveFastRefreshes();
#ifdef EINK_LIMIT_GHOSTING_PX
checkExcessiveGhosting();
#endif
checkFastRequested();
if (refresh == UNSPECIFIED)
LOG_WARN("There was a flaw in the determineMode() logic");
// -- Decision has been reached --
applyRefreshMode();
adjustRefreshCounters();
#ifdef EINK_LIMIT_GHOSTING_PX
// Full refresh clears any ghosting
if (refresh == FULL)
resetGhostPixelTracking();
#endif
// Return - call a refresh or not?
if (refresh == SKIPPED)
return false; // Don't trigger a refresh
else
return true; // Do trigger a refresh
}
// Is this the very first frame?
void EInkDynamicDisplay::checkInitialized()
{
if (!initialized) {
// Undo GxEPD2_BW::partialWindow(), if set by developer in EInkDisplay::connect()
configForFullRefresh();
// Clear any existing image, so we can draw logo with fast-refresh, but also to set GxEPD2_EPD::_initial_write
adafruitDisplay->clearScreen();
LOG_DEBUG("initialized, ");
initialized = true;
// Use a fast-refresh for the next frame; no skipping or else blank screen when waking from deep sleep
addFrameFlag(DEMAND_FAST);
}
}
// Was a frame skipped (rate, display busy) that should have been a FAST refresh?
void EInkDynamicDisplay::checkForPromotion()
{
// If a frame was skipped (rate, display busy), then promote a BACKGROUND frame
// Because we DID want a RESPONSIVE/COSMETIC/DEMAND_FULL frame last time, we just didn't get it
switch (previousReason) {
case ASYNC_REFRESH_BLOCKED_DEMANDFAST:
addFrameFlag(DEMAND_FAST);
break;
case ASYNC_REFRESH_BLOCKED_COSMETIC:
addFrameFlag(COSMETIC);
break;
case ASYNC_REFRESH_BLOCKED_RESPONSIVE:
case EXCEEDED_RATELIMIT_FAST:
addFrameFlag(RESPONSIVE);
break;
default:
break;
}
}
// Is it too soon for another frame of this type?
void EInkDynamicDisplay::checkRateLimiting()
{
// Sanity check: millis() overflow - just let the update run..
if (previousRunMs > millis())
return;
// Skip update: too soon for BACKGROUND
if (frameFlags == BACKGROUND) {
if (Throttle::isWithinTimespanMs(previousRunMs, 30000)) {
refresh = SKIPPED;
reason = EXCEEDED_RATELIMIT_FULL;
return;
}
}
// No rate-limit for these special cases
if (frameFlags & COSMETIC || frameFlags & DEMAND_FAST)
return;
// Skip update: too soon for RESPONSIVE
if (frameFlags & RESPONSIVE) {
if (Throttle::isWithinTimespanMs(previousRunMs, 1000)) {
refresh = SKIPPED;
reason = EXCEEDED_RATELIMIT_FAST;
LOG_DEBUG("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags);
return;
}
}
}
// Is this frame COSMETIC (splash screens?)
void EInkDynamicDisplay::checkCosmetic()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// A full refresh is requested for cosmetic purposes: we have a decision
if (frameFlags & COSMETIC) {
refresh = FULL;
reason = FLAGGED_COSMETIC;
LOG_DEBUG("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags);
}
}
// Is this a one-off special circumstance, where we REALLY want a fast refresh?
void EInkDynamicDisplay::checkDemandingFast()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// A fast refresh is demanded: we have a decision
if (frameFlags & DEMAND_FAST) {
refresh = FAST;
reason = FLAGGED_DEMAND_FAST;
LOG_DEBUG("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags);
}
}
// Does the new frame match the currently displayed image?
void EInkDynamicDisplay::checkFrameMatchesPrevious()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// If frame is *not* a duplicate, abort the check
if (imageHash != previousImageHash)
return;
#if !defined(EINK_BACKGROUND_USES_FAST)
// If BACKGROUND, and last update was FAST: redraw the same image in FULL (for display health + image quality)
if (frameFlags == BACKGROUND && fastRefreshCount > 0) {
refresh = FULL;
reason = REDRAW_WITH_FULL;
LOG_DEBUG("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags);
return;
}
#endif
// Not redrawn, not COSMETIC, not DEMAND_FAST
refresh = SKIPPED;
reason = FRAME_MATCHED_PREVIOUS;
LOG_DEBUG("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags);
}
// Have too many fast-refreshes occurred consecutively, since last full refresh?
void EInkDynamicDisplay::checkConsecutiveFastRefreshes()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// Bypass limit if UNLIMITED_FAST mode is active
if (frameFlags & UNLIMITED_FAST) {
refresh = FAST;
reason = NO_OBJECTIONS;
LOG_DEBUG("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags);
return;
}
// If too many FAST refreshes consecutively - force a FULL refresh
if (fastRefreshCount >= EINK_LIMIT_FASTREFRESH) {
refresh = FULL;
reason = EXCEEDED_LIMIT_FASTREFRESH;
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags);
}
}
// No objections, we can perform fast-refresh, if desired
void EInkDynamicDisplay::checkFastRequested()
{
if (refresh != UNSPECIFIED)
return;
if (frameFlags == BACKGROUND) {
#ifdef EINK_BACKGROUND_USES_FAST
// If we want BACKGROUND to use fast. (FULL only when a limit is hit)
refresh = FAST;
reason = BACKGROUND_USES_FAST;
LOG_DEBUG("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount,
frameFlags);
#else
// If we do want to use FULL for BACKGROUND updates
refresh = FULL;
reason = FLAGGED_BACKGROUND;
LOG_DEBUG("refresh=FULL, reason=FLAGGED_BACKGROUND");
#endif
}
// Sanity: confirm that we did ask for a RESPONSIVE frame.
if (frameFlags & RESPONSIVE) {
refresh = FAST;
reason = NO_OBJECTIONS;
LOG_DEBUG("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags);
}
}
// Reset the timer used for rate-limiting
void EInkDynamicDisplay::resetRateLimiting()
{
previousRunMs = millis();
}
// Generate a hash of this frame, to compare against previous update
void EInkDynamicDisplay::hashImage()
{
imageHash = 0;
// Sum all bytes of the image buffer together
for (uint16_t b = 0; b < (displayWidth / 8) * displayHeight; b++) {
imageHash ^= buffer[b] << b;
}
}
// Store the results of determineMode() for future use, and reset for next call
void EInkDynamicDisplay::storeAndReset()
{
previousFrameFlags = frameFlags;
previousRefresh = refresh;
previousReason = reason;
// Only store image hash if the display will update
if (refresh != SKIPPED) {
previousImageHash = imageHash;
}
frameFlags = BACKGROUND;
refresh = UNSPECIFIED;
}
#ifdef EINK_LIMIT_GHOSTING_PX
// Count how many ghost pixels the new image will display
void EInkDynamicDisplay::countGhostPixels()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
// Start a new count
ghostPixelCount = 0;
// Check new image, bit by bit, for any white pixels at locations marked "dirty"
for (uint16_t i = 0; i < displayBufferSize; i++) {
for (uint8_t bit = 0; bit < 7; bit++) {
const bool dirty = (dirtyPixels[i] >> bit) & 1; // Has pixel location been drawn to since full-refresh?
const bool shouldBeBlank = !((buffer[i] >> bit) & 1); // Is pixel location white in the new image?
// If pixel is (or has been) black since last full-refresh, and now is white: ghosting
if (dirty && shouldBeBlank)
ghostPixelCount++;
// Update the dirty status for this pixel - will this location become a ghost if set white in future?
if (!dirty && !shouldBeBlank)
dirtyPixels[i] |= (1 << bit);
}
}
LOG_DEBUG("ghostPixels=%hu, ", ghostPixelCount);
}
// Check if ghost pixel count exceeds the defined limit
void EInkDynamicDisplay::checkExcessiveGhosting()
{
// If a decision was already reached, don't run the check
if (refresh != UNSPECIFIED)
return;
countGhostPixels();
// If too many ghost pixels, select full refresh
if (ghostPixelCount > EINK_LIMIT_GHOSTING_PX) {
refresh = FULL;
reason = EXCEEDED_GHOSTINGLIMIT;
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags);
}
}
// Clear the dirty pixels array. Call when full-refresh cleans the display.
void EInkDynamicDisplay::resetGhostPixelTracking()
{
// Copy the current frame into dirtyPixels[] from the display buffer
memcpy(dirtyPixels.get(), EInkDisplay::buffer, EInkDisplay::displayBufferSize);
}
#endif // EINK_LIMIT_GHOSTING_PX
// Handle any asyc tasks
void EInkDynamicDisplay::onNotify(uint32_t notification)
{
// Which task
switch (notification) {
case DUE_POLL_ASYNCREFRESH:
pollAsyncRefresh();
break;
}
}
#ifdef HAS_EINK_ASYNCFULL
// Public: wait for an refresh already in progress, then run the post-update code. See Screen::setScreensaverFrames()
void EInkDynamicDisplay::joinAsyncRefresh()
{
// If no async refresh running, nothing to do
if (!asyncRefreshRunning)
return;
LOG_DEBUG("Join an async refresh in progress");
// Continually poll the BUSY pin
while (adafruitDisplay->epd2.isBusy())
yield();
// If asyncRefreshRunning flag is still set, but display's BUSY pin reports the refresh is done
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
asyncRefreshRunning = false; // Unset the flag
LOG_DEBUG("Refresh complete");
// Note: this code only works because of a modification to meshtastic/GxEPD2.
// It is only equipped to intercept calls to nextPage()
}
// Called from NotifiedWorkerThread. Run the post-update code if the hardware is ready
void EInkDynamicDisplay::pollAsyncRefresh()
{
// In theory, this condition should never be met
if (!asyncRefreshRunning)
return;
// Still running, check back later
if (adafruitDisplay->epd2.isBusy()) {
// Schedule next call of pollAsyncRefresh()
NotifiedWorkerThread::notifyLater(intervalPollAsyncRefresh, DUE_POLL_ASYNCREFRESH, true);
return;
}
// If asyncRefreshRunning flag is still set, but display's BUSY pin reports the refresh is done
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
asyncRefreshRunning = false; // Unset the flag
LOG_DEBUG("Async full-refresh complete");
// Note: this code only works because of a modification to meshtastic/GxEPD2.
// It is only equipped to intercept calls to nextPage()
}
// Check the status of "async full-refresh"; skip if running
void EInkDynamicDisplay::checkBusyAsyncRefresh()
{
// No refresh taking place, continue with determineMode()
if (!asyncRefreshRunning)
return;
// Full refresh still running
if (adafruitDisplay->epd2.isBusy()) {
// No refresh
refresh = SKIPPED;
// Set the reason, marking what type of frame we're skipping
if (frameFlags & DEMAND_FAST)
reason = ASYNC_REFRESH_BLOCKED_DEMANDFAST;
else if (frameFlags & COSMETIC)
reason = ASYNC_REFRESH_BLOCKED_COSMETIC;
else if (frameFlags & RESPONSIVE)
reason = ASYNC_REFRESH_BLOCKED_RESPONSIVE;
else
reason = ASYNC_REFRESH_BLOCKED_BACKGROUND;
return;
}
// Async refresh appears to have stopped, but wasn't caught by onNotify()
else
pollAsyncRefresh(); // Check (and terminate) the async refresh manually
}
// Hold control while an async refresh runs
void EInkDynamicDisplay::awaitRefresh()
{
// Continually poll the BUSY pin
while (adafruitDisplay->epd2.isBusy())
yield();
// End the full-refresh process
adafruitDisplay->endAsyncFull(); // Run the end of nextPage() code
EInkDisplay::endUpdate(); // Run base-class code to finish off update (NOT our derived class override)
asyncRefreshRunning = false; // Unset the flag
}
#endif // HAS_EINK_ASYNCFULL
#endif // USE_EINK_DYNAMICDISPLAY
-155
View File
@@ -1,155 +0,0 @@
#pragma once
#include "configuration.h"
#include <memory>
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
#include "EInkDisplay2.h"
#include "GxEPD2_BW.h"
#include "concurrency/NotifiedWorkerThread.h"
/*
Derives from the EInkDisplay adapter class.
Accepts suggestions from Screen class about frame type.
Determines which refresh type is most suitable.
(Full, Fast, Skip)
*/
class EInkDynamicDisplay : public EInkDisplay, protected concurrency::NotifiedWorkerThread
{
public:
// Constructor
// ( Parameters unused, passed to EInkDisplay. Maintains compatibility OLEDDisplay class )
EInkDynamicDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus);
~EInkDynamicDisplay();
// Methods to enable or disable unlimited fast refresh mode
void enableUnlimitedFastMode() { addFrameFlag(UNLIMITED_FAST); }
void disableUnlimitedFastMode() { frameFlags = (frameFlagTypes)(frameFlags & ~UNLIMITED_FAST); }
// What kind of frame is this
enum frameFlagTypes : uint8_t {
BACKGROUND = (1 << 0), // For frames via display()
RESPONSIVE = (1 << 1), // For frames via forceDisplay()
COSMETIC = (1 << 2), // For splashes
DEMAND_FAST = (1 << 3), // Special case only
BLOCKING = (1 << 4), // Modifier - block while refresh runs
UNLIMITED_FAST = (1 << 5)
};
void addFrameFlag(frameFlagTypes flag);
// Set the correct frame flag, then call universal "update()" method
void display() override;
bool forceDisplay(uint32_t msecLimit) override; // Shadows base class. Parameter and return val unused.
protected:
enum refreshTypes : uint8_t { // Which refresh operation will be used
UNSPECIFIED,
FULL,
FAST,
SKIPPED,
};
enum reasonTypes : uint8_t { // How was the decision reached
NO_OBJECTIONS,
ASYNC_REFRESH_BLOCKED_DEMANDFAST,
ASYNC_REFRESH_BLOCKED_COSMETIC,
ASYNC_REFRESH_BLOCKED_RESPONSIVE,
ASYNC_REFRESH_BLOCKED_BACKGROUND,
EXCEEDED_RATELIMIT_FAST,
EXCEEDED_RATELIMIT_FULL,
FLAGGED_COSMETIC,
FLAGGED_DEMAND_FAST,
EXCEEDED_LIMIT_FASTREFRESH,
EXCEEDED_GHOSTINGLIMIT,
FRAME_MATCHED_PREVIOUS,
BACKGROUND_USES_FAST,
FLAGGED_BACKGROUND,
REDRAW_WITH_FULL,
};
enum notificationTypes : uint8_t { // What was onNotify() called for
NONE = 0, // This behavior (NONE=0) is fixed by NotifiedWorkerThread class
DUE_POLL_ASYNCREFRESH = 1,
};
const uint32_t intervalPollAsyncRefresh = 100;
void onNotify(uint32_t notification) override; // Handle any async tasks - overrides NotifiedWorkerThread
void configForFastRefresh(); // GxEPD2 code to set fast-refresh
void configForFullRefresh(); // GxEPD2 code to set full-refresh
bool determineMode(); // Assess situation, pick a refresh type
void applyRefreshMode(); // Run any relevant GxEPD2 code, so next update will use correct refresh type
void adjustRefreshCounters(); // Update fastRefreshCount
bool update(); // Trigger the display update - determine mode, then call base class
void endOrDetach(); // Run the post-update code, or delegate it off to checkBusyAsyncRefresh()
// Checks as part of determineMode()
void checkInitialized(); // Is this the very first frame?
void checkForPromotion(); // Was a frame skipped (rate, display busy) that should have been a FAST refresh?
void checkRateLimiting(); // Is this frame too soon?
void checkCosmetic(); // Was the COSMETIC flag set?
void checkDemandingFast(); // Was the DEMAND_FAST flag set?
void checkFrameMatchesPrevious(); // Does the new frame match the existing display image?
void checkConsecutiveFastRefreshes(); // Too many fast-refreshes consecutively?
void checkFastRequested(); // Was the flag set for RESPONSIVE, or only BACKGROUND?
void resetRateLimiting(); // Set previousRunMs - this now counts as an update, for rate-limiting
void hashImage(); // Generate a hashed version of this frame, to compare against previous update
void storeAndReset(); // Keep results of determineMode() for later, tidy-up for next call
// What we are determining for this frame
frameFlagTypes frameFlags = BACKGROUND; // Frame characteristics - determineMode() input
refreshTypes refresh = UNSPECIFIED; // Refresh type - determineMode() output
reasonTypes reason = NO_OBJECTIONS; // Reason - why was refresh type used
// What happened last time determineMode() ran
frameFlagTypes previousFrameFlags = BACKGROUND; // (Previous) Frame flags
refreshTypes previousRefresh = UNSPECIFIED; // (Previous) Outcome
reasonTypes previousReason = NO_OBJECTIONS; // (Previous) Reason
bool initialized = false; // Have we drawn at least one frame yet?
uint32_t previousRunMs = -1; // When did determineMode() last run (rather than rejecting for rate-limiting)
uint32_t imageHash = 0; // Hash of the current frame. Don't bother updating if nothing has changed!
uint32_t previousImageHash = 0; // Hash of the previous update's frame
uint32_t fastRefreshCount = 0; // How many fast-refreshes consecutively since last full refresh?
refreshTypes currentConfig = FULL; // Which refresh type is GxEPD2 currently configured for
// Optional - track ghosting, pixel by pixel
// May 2024: no longer used by any display. Kept for possible future use.
#ifdef EINK_LIMIT_GHOSTING_PX
void countGhostPixels(); // Count any pixels which have moved from black to white since last full-refresh
void checkExcessiveGhosting(); // Check if ghosting exceeds defined limit
void resetGhostPixelTracking(); // Clear the dirty pixels array. Call when full-refresh cleans the display.
std::unique_ptr<uint8_t[]> dirtyPixels; // Any pixels that have been black since last full-refresh (dynamically allocated mem)
uint32_t ghostPixelCount = 0; // Number of pixels with problematic ghosting. Retained here for LOG_DEBUG use
#endif
// Conditional - async full refresh - only with modified meshtastic/GxEPD2
#if defined(HAS_EINK_ASYNCFULL)
public:
void joinAsyncRefresh(); // Main thread joins an async refresh already in progress. Blocks, then runs post-update code
protected:
void pollAsyncRefresh(); // Run the post-update code if the hardware is ready
void checkBusyAsyncRefresh(); // Check if display is busy running an async full-refresh (rejecting new frames)
void awaitRefresh(); // Hold control while an async refresh runs
void endUpdate() override {} // Disable base-class behavior of running post-update immediately after forceDisplay()
bool asyncRefreshRunning = false; // Flag, checked by checkBusyAsyncRefresh()
#else
public:
void joinAsyncRefresh() {} // Dummy method
protected:
void pollAsyncRefresh() {} // Dummy method. In theory, not reachable
#endif
};
// Hide the ugly casts used in Screen.cpp
#define EINK_ADD_FRAMEFLAG(display, flag) static_cast<EInkDynamicDisplay *>(display)->addFrameFlag(EInkDynamicDisplay::flag)
#define EINK_JOIN_ASYNCREFRESH(display) static_cast<EInkDynamicDisplay *>(display)->joinAsyncRefresh()
#else // !USE_EINK_DYNAMICDISPLAY
// Dummy-macro, removes the need for include guards
#define EINK_ADD_FRAMEFLAG(display, flag)
#define EINK_JOIN_ASYNCREFRESH(display)
#endif
-427
View File
@@ -1,427 +0,0 @@
#include "EInkParallelDisplay.h"
#ifdef USE_EINK_PARALLELDISPLAY
#include "Wire.h"
#include "variant.h"
#include <Arduino.h>
#include <atomic>
#include <stdlib.h>
#include <string.h>
#include "FastEPD.h"
// Thresholds for choosing partial vs full update
#ifndef EPD_PARTIAL_THRESHOLD_ROWS
#define EPD_PARTIAL_THRESHOLD_ROWS 128 // if changed region <= this many rows, prefer partial
#endif
#ifndef EPD_FULLSLOW_PERIOD
#define EPD_FULLSLOW_PERIOD 100 // every N full updates do a slow (CLEAR_SLOW) full refresh
#endif
#ifndef EPD_RESPONSIVE_MIN_MS
#define EPD_RESPONSIVE_MIN_MS 1000 // simple rate-limit (ms) for responsive updates
#endif
EInkParallelDisplay::EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rot) : epaper(nullptr), rotation(rot)
{
LOG_INFO("init EInkParallelDisplay");
// Set dimensions in OLEDDisplay base class
this->geometry = GEOMETRY_RAWMODE;
this->displayWidth = width;
this->displayHeight = height;
// Round shortest side up to nearest byte, to prevent truncation causing an undersized buffer
uint16_t shortSide = min(width, height);
uint16_t longSide = max(width, height);
if (shortSide % 8 != 0)
shortSide = (shortSide | 7) + 1;
this->displayBufferSize = longSide * (shortSide / 8);
#ifdef EINK_LIMIT_GHOSTING_PX
// allocate dirty pixel buffer same size as epaper buffers (rowBytes * height)
size_t rowBytes = (this->displayWidth + 7) / 8;
dirtyPixelsSize = rowBytes * this->displayHeight;
dirtyPixels = (uint8_t *)calloc(dirtyPixelsSize, 1);
ghostPixelCount = 0;
#endif
}
EInkParallelDisplay::~EInkParallelDisplay()
{
#ifdef EINK_LIMIT_GHOSTING_PX
if (dirtyPixels) {
free(dirtyPixels);
dirtyPixels = nullptr;
}
#endif
// If an async full update is running, wait for it to finish
if (asyncFullRunning.load()) {
// wait a short while for task to finish
for (int i = 0; i < 50 && asyncFullRunning.load(); ++i) {
delay(50);
}
if (asyncTaskHandle) {
// Let it finish or delete it
vTaskDelete(asyncTaskHandle);
asyncTaskHandle = nullptr;
}
}
delete epaper;
}
/*
* Called by the OLEDDisplay::init() path.
*/
bool EInkParallelDisplay::connect()
{
LOG_INFO("Do EPD init");
if (!epaper) {
epaper = new FASTEPD;
#if defined(T5_S3_EPAPER_PRO_V1)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
// initialize all port 0 pins (0-7) as outputs / HIGH
for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT);
epaper->ioWrite(i, HIGH);
}
#else
#error "unsupported EPD device!"
#endif
}
// epaper->setRotation(rotation); // does not work, messes up width/height
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true);
#ifdef EINK_LIMIT_GHOSTING_PX
// After a full/clear the dirty tracking should be reset
resetGhostPixelTracking();
#endif
return true;
}
/*
* sendCommand - simple passthrough (not required for epd_driver-based path)
*/
void EInkParallelDisplay::sendCommand(uint8_t com)
{
LOG_DEBUG("EInkParallelDisplay::sendCommand %d", (int)com);
}
/*
* Start a background task that will perform a blocking fullUpdate(). This lets
* display() return quickly while the heavy refresh runs in the background.
*/
void EInkParallelDisplay::startAsyncFullUpdate(int clearMode)
{
if (asyncFullRunning.load())
return; // already running
asyncFullRunning.store(true);
// pass 'this' as parameter
BaseType_t rc = xTaskCreatePinnedToCore(EInkParallelDisplay::asyncFullUpdateTask, "epd_full", 4096 / sizeof(StackType_t),
this, 2, &asyncTaskHandle,
#if CONFIG_FREERTOS_UNICORE
0
#else
1
#endif
);
if (rc != pdPASS) {
LOG_WARN("Failed to create async full-update task, falling back to blocking update");
epaper->fullUpdate(clearMode, false);
epaper->backupPlane();
asyncFullRunning.store(false);
asyncTaskHandle = nullptr;
}
}
/*
* FreeRTOS task entry: runs the full update and then backs up plane.
*/
void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters)
{
EInkParallelDisplay *self = static_cast<EInkParallelDisplay *>(pvParameters);
if (!self) {
vTaskDelete(nullptr);
return;
}
// choose CLEAR_SLOW occasionally
int clearMode = CLEAR_FAST;
if (self->fastRefreshCount >= EPD_FULLSLOW_PERIOD) {
clearMode = CLEAR_SLOW;
self->fastRefreshCount = 0;
} else {
// when running async full, treat it as a full so reset fast count
self->fastRefreshCount = 0;
}
self->epaper->fullUpdate(clearMode, false);
self->epaper->backupPlane();
#ifdef EINK_LIMIT_GHOSTING_PX
// A full refresh clears ghosting state
self->resetGhostPixelTracking();
#endif
self->asyncFullRunning.store(false);
self->asyncTaskHandle = nullptr;
// delete this task
vTaskDelete(nullptr);
}
/*
* Convert the OLEDDisplay buffer (vertical byte layout) into the 1bpp horizontal-bytes
* buffer used by the FASTEPD library. For performance we write directly into FASTEPD's
* currentBuffer() while comparing against previousBuffer() to detect changed rows.
* After conversion we call FASTEPD::partialUpdate() or FASTEPD::fullUpdate() according
* to a heuristic so only the minimal region is refreshed.
*/
void EInkParallelDisplay::display(void)
{
const uint16_t w = this->displayWidth;
const uint16_t h = this->displayHeight;
// Simple rate limiting: avoid very-frequent responsive updates
uint32_t nowMs = millis();
if (lastUpdateMs != 0 && (nowMs - lastUpdateMs) < EPD_RESPONSIVE_MIN_MS) {
LOG_DEBUG("rate-limited, skipping update");
return;
}
// bytes per row in epd format (one byte = 8 horizontal pixels)
const uint32_t rowBytes = (w + 7) / 8;
// Get pointers to internal buffers
uint8_t *cur = epaper->currentBuffer();
const uint8_t *prev = epaper->previousBuffer(); // may be NULL on first init
// Track changed row range while converting
int newTop = h; // min changed row (initialized to out-of-range)
int newBottom = -1; // max changed row
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// Track changed byte column range (for clipped fullUpdate fallback)
int newLeftByte = (int)rowBytes;
int newRightByte = -1;
#endif
// Compute a quick hash of the incoming OLED buffer (so we can skip identical frames)
uint32_t imageHash = 0;
uint32_t bufBytes = (w / 8) * h; // vertical-byte layout size
for (uint32_t bi = 0; bi < bufBytes; ++bi) {
imageHash ^= ((uint32_t)buffer[bi]) << (bi & 31);
}
if (imageHash == previousImageHash) {
// LOG_DEBUG("image identical to previous, skipping update");
return;
}
#ifdef EINK_LIMIT_GHOSTING_PX
// reset ghost count for this conversion pass; we'll mark bits that change
ghostPixelCount = 0;
#endif
// Convert: OLED buffer layout -> FASTEPD 1bpp horizontal-bytes layout into cur,
// comparing against prev when available to detect changes.
for (uint32_t y = 0; y < h; ++y) {
const uint32_t base = (y >> 3) * w; // (y/8) * width
const uint8_t bitMask = (uint8_t)(1u << (y & 7)); // mask for this row in vertical-byte layout
const uint32_t rowBase = y * rowBytes;
// process full 8-pixel bytes
for (uint32_t xb = 0; xb < rowBytes; ++xb) {
uint32_t x0 = xb * 8;
// read up to 8 source bytes (vertical-byte per column)
uint8_t b0 = (x0 + 0 < w) ? buffer[base + x0 + 0] : 0;
uint8_t b1 = (x0 + 1 < w) ? buffer[base + x0 + 1] : 0;
uint8_t b2 = (x0 + 2 < w) ? buffer[base + x0 + 2] : 0;
uint8_t b3 = (x0 + 3 < w) ? buffer[base + x0 + 3] : 0;
uint8_t b4 = (x0 + 4 < w) ? buffer[base + x0 + 4] : 0;
uint8_t b5 = (x0 + 5 < w) ? buffer[base + x0 + 5] : 0;
uint8_t b6 = (x0 + 6 < w) ? buffer[base + x0 + 6] : 0;
uint8_t b7 = (x0 + 7 < w) ? buffer[base + x0 + 7] : 0;
// build output byte: MSB = leftmost pixel
uint8_t out = 0;
out |= (uint8_t)((b0 & bitMask) ? 0x80 : 0x00);
out |= (uint8_t)((b1 & bitMask) ? 0x40 : 0x00);
out |= (uint8_t)((b2 & bitMask) ? 0x20 : 0x00);
out |= (uint8_t)((b3 & bitMask) ? 0x10 : 0x00);
out |= (uint8_t)((b4 & bitMask) ? 0x08 : 0x00);
out |= (uint8_t)((b5 & bitMask) ? 0x04 : 0x00);
out |= (uint8_t)((b6 & bitMask) ? 0x02 : 0x00);
out |= (uint8_t)((b7 & bitMask) ? 0x01 : 0x00);
// handle partial byte at end of row by masking off invalid bits
uint8_t mask = 0xFF;
uint32_t bitsRemain = (w > x0) ? (w - x0) : 0;
if (bitsRemain > 0 && bitsRemain < 8) {
mask = (uint8_t)(0xFF << (8 - bitsRemain));
out &= mask;
}
// invert to FASTEPD polarity
out = (~out) & mask;
uint32_t pos = rowBase + xb;
uint8_t prevVal = prev ? (prev[pos] & mask) : 0x00;
// Consider this byte changed if previous buffer differs (or prev is null)
bool changed = (prev == nullptr) || (prevVal != out);
#ifdef EINK_LIMIT_GHOSTING_PX
if (changed && prev)
markDirtyBits(prev, pos, mask, out);
#endif
// mark row changed only if the previous buffer differs
if (changed) {
if (y < (uint32_t)newTop)
newTop = y;
if ((int)y > newBottom)
newBottom = y;
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// record changed column bytes
if ((int)xb < newLeftByte)
newLeftByte = (int)xb;
if ((int)xb > newRightByte)
newRightByte = (int)xb;
#endif
}
// Always write the computed value into the current buffer (avoid leaving stale bytes)
cur[pos] = (cur[pos] & ~mask) | out;
}
}
// If nothing changed, avoid any panel update
if (newBottom < 0) {
LOG_DEBUG("no pixel changes detected, skipping update (conv)");
previousImageHash = imageHash; // still remember that frame
return;
}
// Choose partial vs full update using heuristic
// Decide if we should force a full update after many fast updates
bool forceFull = (fastRefreshCount >= EPD_FULLSLOW_PERIOD);
#ifdef EINK_LIMIT_GHOSTING_PX
// If ghost pixels exceed limit, force a full update to clear ghosting
if (ghostPixelCount > ghostPixelLimit) {
LOG_WARN("ghost pixels %u > limit %u, forcing full refresh", ghostPixelCount, ghostPixelLimit);
forceFull = true;
}
#endif
// Compute pixel bounds from newTop/newBottom
int startRow = (newTop / 8) * 8;
int endRow = (newBottom / 8) * 8 + 7;
LOG_DEBUG("EPD update rows=%d..%d alignedRows=%d..%d rowBytes=%u", newTop, newBottom, startRow, endRow, rowBytes);
if (epaper->getMode() == BB_MODE_1BPP && !forceFull && (newBottom - newTop) <= EPD_PARTIAL_THRESHOLD_ROWS) {
// Prefer partial update path if driver is reliable; otherwise use clipped fullUpdate fallback.
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// Workaround for FastEPD partial update bug: use clipped fullUpdate instead
// Build a pixel rectangle for a clipped fullUpdate using the changed columns
int startCol = (newLeftByte <= newRightByte) ? (newLeftByte * 8) : 0;
int endCol = (newLeftByte <= newRightByte) ? ((newRightByte + 1) * 8 - 1) : (w - 1);
BB_RECT rect{startCol, startRow, endCol - startCol + 1, endRow - startRow + 1};
// LOG_DEBUG("Using clipped fullUpdate rect x=%d y=%d w=%d h=%d", rect.x, rect.y, rect.w, rect.h);
epaper->fullUpdate(CLEAR_FAST, false, &rect);
#else
// Use rows for partial update
LOG_DEBUG("calling partialUpdate startRow=%d endRow=%d", startRow, endRow);
epaper->partialUpdate(true, startRow, endRow);
#endif
epaper->backupPlane();
fastRefreshCount++;
} else {
// Full update: run async if possible (startAsyncFullUpdate will fall back to blocking)
startAsyncFullUpdate(forceFull ? CLEAR_SLOW : CLEAR_FAST);
}
lastUpdateMs = millis();
previousImageHash = imageHash;
// Keep same behavior as before
lastDrawMsec = millis();
}
#ifdef EINK_LIMIT_GHOSTING_PX
// markDirtyBits: mark per-bit dirty flags and update ghostPixelCount
void EInkParallelDisplay::markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out)
{
// defensive: need dirtyPixels allocated and prevBuf valid
if (!dirtyPixels || !prevBuf)
return;
// 'out' is in FASTEPD polarity (1 = black, 0 = white)
uint8_t newBlack = out & mask; // bits that will be black now
uint8_t newWhite = (~out) & mask; // bits that will be white now
// previously recorded dirty bits for this byte
uint8_t before = dirtyPixels[pos];
// Ghost bits: bits that were previously marked dirty and are now being driven white
uint8_t ghostBits = before & newWhite;
if (ghostBits) {
ghostPixelCount += __builtin_popcount((unsigned)ghostBits);
}
// Only mark bits dirty when they turn black now (accumulate until a full refresh)
uint8_t newlyDirty = newBlack & (~before);
if (newlyDirty) {
dirtyPixels[pos] |= newlyDirty;
}
}
// reset ghost tracking (call after a full refresh)
void EInkParallelDisplay::resetGhostPixelTracking()
{
if (!dirtyPixels)
return;
memset(dirtyPixels, 0, dirtyPixelsSize);
ghostPixelCount = 0;
}
#endif
/*
* forceDisplay: use lastDrawMsec
*/
bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
{
uint32_t now = millis();
if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) {
display();
return true;
}
return false;
}
void EInkParallelDisplay::endUpdate()
{
{
// ensure any async full update is started/completed
if (asyncFullRunning.load()) {
// nothing to do; background task will run and call backupPlane when done
} else {
epaper->fullUpdate(CLEAR_FAST, false);
epaper->backupPlane();
#ifdef EINK_LIMIT_GHOSTING_PX
resetGhostPixelTracking();
#endif
}
}
}
#endif
-69
View File
@@ -1,69 +0,0 @@
#pragma once
#include "configuration.h"
#ifdef USE_EINK_PARALLELDISPLAY
#include <OLEDDisplay.h>
#include <atomic>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
class FASTEPD;
/**
* Adapter for E-Ink 8-bit parallel displays (EPD), specifically devices supported by FastEPD library
*/
class EInkParallelDisplay : public OLEDDisplay
{
public:
enum EpdRotation {
EPD_ROT_LANDSCAPE = 0,
EPD_ROT_PORTRAIT = 90,
EPD_ROT_INVERTED_LANDSCAPE = 180,
EPD_ROT_INVERTED_PORTRAIT = 270,
};
EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rotation);
virtual ~EInkParallelDisplay();
// OLEDDisplay virtuals
bool connect() override;
void sendCommand(uint8_t com) override;
int getBufferOffset(void) override { return 0; }
void display(void) override;
bool forceDisplay(uint32_t msecLimit = 1000);
void endUpdate();
protected:
uint32_t lastDrawMsec = 0;
FASTEPD *epaper;
private:
// Async full-refresh support
std::atomic<bool> asyncFullRunning{false};
TaskHandle_t asyncTaskHandle = nullptr;
void startAsyncFullUpdate(int clearMode);
static void asyncFullUpdateTask(void *pvParameters);
#ifdef EINK_LIMIT_GHOSTING_PX
// helpers
void resetGhostPixelTracking();
void markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out);
void countGhostPixelsAndMaybePromote(int &newTop, int &newBottom, bool &forceFull);
// per-bit dirty buffer (same format as epaper buffers): one bit == one pixel
uint8_t *dirtyPixels = nullptr;
size_t dirtyPixelsSize = 0;
uint32_t ghostPixelCount = 0;
uint32_t ghostPixelLimit = EINK_LIMIT_GHOSTING_PX;
#endif
EpdRotation rotation;
uint32_t previousImageHash = 0;
uint32_t lastUpdateMs = 0;
int fastRefreshCount = 0;
};
#endif
-135
View File
@@ -1,135 +0,0 @@
// Wrapper class for GxEPD2_BW
// Generic signature at build-time, so that we can detect display model at run-time
// Workaround for issue of GxEPD2_BW objects not having a shared base class
// Only exposes methods which we are actually using
template <typename Driver0, typename Driver1> class GxEPD2_Multi
{
public:
void drawPixel(int16_t x, int16_t y, uint16_t color)
{
if (which == 0)
driver0->drawPixel(x, y, color);
else
driver1->drawPixel(x, y, color);
}
bool nextPage()
{
if (which == 0)
return driver0->nextPage();
else
return driver1->nextPage();
}
void hibernate()
{
if (which == 0)
driver0->hibernate();
else
driver1->hibernate();
}
void init(uint32_t serial_diag_bitrate = 0)
{
if (which == 0)
driver0->init(serial_diag_bitrate);
else
driver1->init(serial_diag_bitrate);
}
void init(uint32_t serial_diag_bitrate, bool initial, uint16_t reset_duration = 20, bool pulldown_rst_mode = false)
{
if (which == 0)
driver0->init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode);
else
driver1->init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode);
}
void setRotation(uint8_t x)
{
if (which == 0)
driver0->setRotation(x);
else
driver1->setRotation(x);
}
void setPartialWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h)
{
if (which == 0)
driver0->setPartialWindow(x, y, w, h);
else
driver1->setPartialWindow(x, y, w, h);
}
void setFullWindow()
{
if (which == 0)
driver0->setFullWindow();
else
driver1->setFullWindow();
}
int16_t width()
{
if (which == 0)
return driver0->width();
else
return driver1->width();
}
int16_t height()
{
if (which == 0)
return driver0->height();
else
return driver1->height();
}
void clearScreen(uint8_t value = 0xFF)
{
if (which == 0)
driver0->clearScreen();
else
driver1->clearScreen();
}
void endAsyncFull()
{
if (which == 0)
driver0->endAsyncFull();
else
driver1->endAsyncFull();
}
// Exposes methods of the GxEPD2_EPD object which is usually available as GxEPD2_BW::epd
class Epd2Wrapper
{
public:
bool isBusy() { return m_epd2->isBusy(); }
GxEPD2_EPD *m_epd2;
} epd2;
// Constructor
// Select driver by passing whichDriver as 0 or 1
GxEPD2_Multi(uint8_t whichDriver, int16_t cs, int16_t dc, int16_t rst, int16_t busy, SPIClass &spi)
{
assert(whichDriver == 0 || whichDriver == 1);
which = whichDriver;
LOG_DEBUG("GxEPD2_Multi driver: %d", which);
if (which == 0) {
driver0 = new GxEPD2_BW<Driver0, Driver0::HEIGHT>(Driver0(cs, dc, rst, busy, spi));
epd2.m_epd2 = &(driver0->epd2);
} else if (which == 1) {
driver1 = new GxEPD2_BW<Driver1, Driver1::HEIGHT>(Driver1(cs, dc, rst, busy, spi));
epd2.m_epd2 = &(driver1->epd2);
}
}
private:
uint8_t which;
GxEPD2_BW<Driver0, Driver0::HEIGHT> *driver0;
GxEPD2_BW<Driver1, Driver1::HEIGHT> *driver1;
};
+46 -195
View File
@@ -27,9 +27,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "configuration.h"
#include "meshUtils.h"
#if HAS_SCREEN
#include "EInkParallelDisplay.h"
#include <OLEDDisplay.h>
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "graphics/BaseUIEInkDisplay.h"
// Provided by each niche-enabled variant's nicheGraphics.h (defined once, in main.cpp TU).
extern NicheGraphics::BaseUIEInkDisplay *setupNicheGraphicsBaseUI();
#endif
#include "DisplayFormatters.h"
#include "TimeFormatters.h"
#include "draw/ClockRenderer.h"
@@ -41,7 +46,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "draw/UIRenderer.h"
#include "graphics/TFTColorRegions.h"
#include "modules/CannedMessageModule.h"
#include "security/LockdownDisplay.h"
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
@@ -120,76 +124,8 @@ static inline void prepareFrameColorRegions()
}
#endif
#ifdef MESHTASTIC_LOCKDOWN
// Static lock screen drawn in place of normal frames when
// meshtastic_security::shouldRedactDisplay() returns true. Renders centered
// "LOCKED" plus battery so the operator can see the device is alive and
// charged without leaking any node/channel/message/position content.
// Draw the LOCKED frame into the host-side framebuffer. Does NOT commit
// to the panel — the caller is responsible for calling display->display()
// once it has composited any overlays on top. Committing here would cause
// visible flicker between "just LOCKED" and "LOCKED + banner overlay" when
// the pairing-PIN special-case in updateUiFrame paints the overlay after
// this returns.
static void drawLockdownLockScreenIntoBuffer(OLEDDisplay *display)
{
display->clear();
const int w = display->getWidth();
const int h = display->getHeight();
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_LARGE);
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, "LOCKED");
display->setFont(FONT_SMALL);
char status[32] = "Connect to unlock";
if (powerStatus && powerStatus->getHasBattery()) {
int pct = powerStatus->getBatteryChargePercent();
snprintf(status, sizeof(status), "Battery %d%%", pct);
}
display->drawString(w / 2, h / 2 + 2, status);
}
// Convenience wrapper for callers that want the LOCKED frame committed
// to the panel immediately and have no overlay to compose on top.
static void drawLockdownLockScreen(OLEDDisplay *display)
{
drawLockdownLockScreenIntoBuffer(display);
display->display();
}
#endif
static inline void updateUiFrame(OLEDDisplayUi *ui)
{
#ifdef MESHTASTIC_LOCKDOWN
if (meshtastic_security::shouldRedactDisplay() && screen != nullptr) {
OLEDDisplay *display = screen->getDisplayDevice();
// Paint LOCKED into the framebuffer WITHOUT committing. We commit
// exactly once at the bottom — after any overlay has been composed
// on top — so the panel never visibly transitions from "just LOCKED"
// to "LOCKED + overlay" mid-frame. Committing twice per cycle was
// the source of the H13 flicker.
drawLockdownLockScreenIntoBuffer(display);
// Special-case the BLE pairing PIN banner. The PIN is needed to
// complete first-pair against a locked device, but the lockdown
// short-circuit would otherwise hide the PIN entirely. The PIN is
// a per-attempt ephemeral pair-handshake artifact, not operator
// content, so compositing it over the LOCKED frame is safe.
//
// Calling ui->update() here would be wrong: it redraws the current
// carousel frame (the dashboard) into the framebuffer before the
// overlay paints, leaving operator content visible underneath the
// banner. Instead we invoke the banner overlay callback directly,
// which paints only the banner box on top of the LOCKED pixels we
// already have in the framebuffer.
if (NotificationRenderer::current_notification_type == notificationTypeEnum::pairing_pin) {
NotificationRenderer::drawBannercallback(display, ui->getUiState());
}
display->display();
return;
}
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
@@ -233,16 +169,6 @@ static inline float wrapHeading360(float heading)
return heading;
}
static inline float wrapDelta180(float delta)
{
if (delta > 180.0f) {
delta -= 360.0f;
} else if (delta < -180.0f) {
delta += 360.0f;
}
return delta;
}
void Screen::setHeading(float heading)
{
const float wrappedHeading = wrapHeading360(heading);
@@ -254,30 +180,37 @@ void Screen::setHeading(float heading)
}
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
float delta = wrapDelta180(wrappedHeading - compassHeading);
float delta = wrappedHeading - compassHeading;
if (delta > 180.0f) {
delta -= 360.0f;
} else if (delta < -180.0f) {
delta += 360.0f;
}
// Adaptive filtering:
// - Strong damping for tiny deltas (jitter)
// - Faster response for larger turns
const float absDelta = (delta >= 0.0f) ? delta : -delta;
if (absDelta >= 1.0f) {
float alpha = 0.35f;
if (absDelta > 25.0f) {
alpha = 0.85f;
} else if (absDelta > 10.0f) {
alpha = 0.65f;
}
float step = delta * alpha;
const float maxStep = 12.0f;
if (step > maxStep) {
step = maxStep;
} else if (step < -maxStep) {
step = -maxStep;
}
compassHeading = wrapHeading360(compassHeading + step);
if (absDelta < 1.0f) {
return;
}
float alpha = 0.35f;
if (absDelta > 25.0f) {
alpha = 0.85f;
} else if (absDelta > 10.0f) {
alpha = 0.65f;
}
float step = delta * alpha;
const float maxStep = 12.0f;
if (step > maxStep) {
step = maxStep;
} else if (step < -maxStep) {
step = -maxStep;
}
compassHeading = wrapHeading360(compassHeading + step);
}
// ==============================
@@ -515,14 +448,9 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR)
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) && !defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
dispdev = new EInkDynamicDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkParallelDisplay(EPD_WIDTH, EPD_HEIGHT, EInkParallelDisplay::EPD_ROT_PORTRAIT);
#elif defined(USE_EINK) && defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
// NicheGraphics-backed BaseUI E-Ink path. Variant provides setupNicheGraphicsBaseUI() in its nicheGraphics.h.
dispdev = setupNicheGraphicsBaseUI();
#elif defined(USE_ST7567)
dispdev = new ST7567Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -655,21 +583,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
setScreensaverFrames(einkScreensaver);
#endif
#ifdef MESHTASTIC_LOCKDOWN
// M19: before turning the panel off, paint a safe frame into the
// OLED's GDDRAM. The panel retains whatever was last written even
// while powered down, so when displayOn() is called later the
// screen would otherwise flash the previous frame's content for
// 16-50 ms before the next ui->update() lands. Painting the
// LOCKED frame now ensures the only thing the operator (or
// someone over their shoulder) can see on wake is the redacted
// view. Gated on lockdown — non-lockdown builds keep the
// previous frame as a UX cue that the display is just dimmed.
// dispdev is dereferenced unguarded throughout this file (incl.
// displayOff() just below), so no null check here.
drawLockdownLockScreen(dispdev);
#endif
#ifdef PIN_EINK_EN
digitalWrite(PIN_EINK_EN, LOW);
#elif defined(PCA_PIN_EINK_EN)
@@ -789,27 +702,6 @@ void Screen::setup()
#endif
LOG_INFO("Applied screen brightness: %d", brightness);
#if defined(MESHTASTIC_LOCKDOWN) && defined(USE_EINK)
// M20: e-ink panels physically retain the last-rendered image without
// power, so a power-cycled lockdown handheld would keep showing
// operator-identifying content (position, messages, node info) until
// the firmware's first natural refresh — which on e-ink can be seconds
// into boot. Force a full refresh to the LOCKED frame here, immediately
// after the display is initialised and before any other rendering, so
// the persistent pixels are wiped to the redacted view before an
// observer can see them.
if (meshtastic_security::shouldRedactDisplay()) {
drawLockdownLockScreen(dispdev);
#if defined(USE_EINK_PARALLELDISPLAY)
// Parallel-display variants drive refresh through a different path;
// a bare drawLockdownLockScreen above lands the frame into the
// panel buffer and the next ui->update() commits it as normal.
#else
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
#endif
}
#endif
// Set custom overlay callbacks
static OverlayCallback overlays[] = {
graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame
@@ -917,16 +809,10 @@ void Screen::setOn(bool on, FrameCallback einkScreensaver)
if (cardKbI2cImpl)
cardKbI2cImpl->toggleBacklight(on);
#endif
if (!on) {
#ifdef MESHTASTIC_LOCKDOWN
// Screen powering off (idle timeout, shutdown, deep sleep) latches
// the screen-lock. Next time the display wakes it shows the LOCKED
// frame until a client authenticates with the passphrase.
meshtastic_security::lockScreen();
#endif
if (!on)
// We handle off commands immediately, because they might be called because the CPU is shutting down
handleSetOn(false, einkScreensaver);
} else
else
enqueueCmd(ScreenCmd{.cmd = Cmd::SET_ON});
}
@@ -962,10 +848,8 @@ void Screen::forceDisplay(bool forceUiUpdate)
}
// Tell EInk class to update the display
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay();
#elif defined(USE_EINK)
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
static_cast<NicheGraphics::BaseUIEInkDisplay *>(dispdev)->forceDisplay();
#endif
#else
// No delay between UI frame rendering
@@ -1033,17 +917,7 @@ int32_t Screen::runOnce()
#endif
#ifndef DISABLE_WELCOME_UNSET
bool suppressRegionOnboard = false;
#ifdef MESHTASTIC_LOCKDOWN
// While lockdown is active and storage is still locked, config.lora.region
// is a deliberate UNSET placeholder — the real region lives in encrypted
// storage and is restored on unlock (see NodeDB's locked-boot path). Don't
// pop the region picker over the lock screen: it would trap input, and the
// operator can't set a region until they unlock anyway.
suppressRegionOnboard = meshtastic_security::shouldRedactDisplay();
#endif
if (!suppressRegionOnboard && !NotificationRenderer::isOverlayBannerShowing() &&
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if defined(OLED_TINY)
menuHandler::LoraRegionPicker();
#else
@@ -1162,12 +1036,7 @@ int32_t Screen::runOnce()
NotificationRenderer::current_notification_type != notificationTypeEnum::text_input &&
!Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) {
// If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead
// Carousel is potentially a major source of E-Ink display wear
#if !defined(EINK_BACKGROUND_USES_FAST)
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC);
#endif
// Carousel rotations let BaseUIEInkDisplay's DisplayHealth debt model decide FAST vs FULL.
LOG_DEBUG("LastScreenTransition exceeded %ums transition to next frame", (millis() - lastScreenTransition));
handleOnPress();
}
@@ -1201,11 +1070,8 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
static FrameCallback screensaverFrame;
static OverlayCallback screensaverOverlay;
#if defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY)
// Join (await) a currently running async refresh, then run the post-update code.
// Avoid skipping of screensaver frame. Would otherwise be handled by NotifiedWorkerThread.
// Join (await) any currently running async refresh before drawing the screensaver frame.
EINK_JOIN_ASYNCREFRESH(dispdev);
#endif
// If: one-off screensaver frame passed as argument. Handles doDeepSleep()
if (einkScreensaver != NULL) {
@@ -1228,23 +1094,17 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay(0);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY)
// Old EInkDisplay class
static_cast<EInkDisplay *>(dispdev)->forceDisplay(0); // Screen::forceDisplay(), but override rate-limit
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
static_cast<NicheGraphics::BaseUIEInkDisplay *>(dispdev)->forceDisplay(0);
#endif
// Prepare now for next frame, shown when display wakes
ui->setOverlays(NULL, 0); // Clear overlay
setFrames(FOCUS_PRESERVE); // Return to normal display updates, showing same frame as before screensaver, ideally
// Pick a refresh method, for when display wakes
#ifdef EINK_HASQUIRK_GHOSTING
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused"
#else
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
#endif
// Pick a refresh method for when the display wakes. RESPONSIVE = FAST; DisplayHealth
// will promote to FULL on its own schedule if FAST debt has built up.
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE);
}
#endif
@@ -1759,15 +1619,6 @@ void Screen::handleStartFirmwareUpdateScreen()
void Screen::blink()
{
#ifdef MESHTASTIC_LOCKDOWN
// L4: defensive guard. blink() paints arbitrary geometry, not node
// data, so it doesn't actually leak today. But it bypasses the normal
// ui->update() path that the lockdown short-circuit gates, so any
// future change that puts content into blink would silently leak past
// redaction. Refuse to draw when the redaction latch is set.
if (meshtastic_security::shouldRedactDisplay())
return;
#endif
setFastFramerate();
uint8_t count = 10;
dispdev->setBrightness(254);
+2 -17
View File
@@ -12,21 +12,7 @@
#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
namespace graphics
{
enum notificationTypeEnum {
none,
text_banner,
selection_picker,
node_picker,
number_picker,
hex_picker,
text_input,
// BLE pairing PIN banner. Treated specially by the lockdown short-circuit
// in Screen.cpp: the PIN is ephemeral (regenerated per pair attempt) and
// not a real secret, so we allow ui->update() to composite it over the
// LOCKED frame. Without this, a first-pair on a locked device cannot
// complete because the PIN never renders.
pairing_pin,
};
enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input };
struct BannerOverlayOptions {
const char *message;
@@ -101,8 +87,7 @@ class Screen
#include <AutoOLEDWire.h>
#endif
#include "EInkDisplay2.h"
#include "EInkDynamicDisplay.h"
#include "BaseUIEInkDisplay.h"
#include "PointStruct.h"
#include "TFTDisplay.h"
#include "TypedQueue.h"
-4
View File
@@ -578,11 +578,7 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
#endif
display->setColor(BLACK);
#if GRAPHICS_TFT_COLORING_ENABLED
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
#else
display->fillRect(0, footerY, connection_icon_width + 1, footerH);
#endif
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
const int bytesPerRow = (connection_icon_width + 7) / 8;
+4 -2
View File
@@ -1533,7 +1533,8 @@ bool TFTDisplay::hasTouch(void)
{
#ifdef RAK14014
return true;
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
!defined(HELTEC_MESH_NODE_T1)
return tft->touch() != nullptr;
#else
return false;
@@ -1552,7 +1553,8 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
} else {
return false;
}
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
!defined(HELTEC_MESH_NODE_T1)
return tft->getTouch(x, y);
#else
return false;
+2 -6
View File
@@ -183,13 +183,9 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
static float segmentHeight = SEGMENT_HEIGHT * 0.75f;
if (!scaleInitialized) {
#ifdef DISPLAY_FORCE_SMALL_FONTS
float screenwidth_target_ratio = 0.70f; // Target 70% of display width (adjustable)
#else
float screenwidth_target_ratio = 0.80f; // Target 80% of display width (adjustable)
#endif
float max_scale = 3.5f; // Safety limit to avoid runaway scaling
float step = 0.05f; // Step increment per iteration
float max_scale = 3.5f; // Safety limit to avoid runaway scaling
float step = 0.05f; // Step increment per iteration
float target_width = display->getWidth() * screenwidth_target_ratio;
float target_height =
+27 -105
View File
@@ -126,7 +126,6 @@ void launchReplyForMessage(const StoredMessage &message, bool freetext)
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
uint32_t menuHandler::pickedNodeNum = 0;
meshtastic_Config_LoRaConfig_RegionCode menuHandler::pendingRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
bool test_enabled = false;
uint8_t test_count = 0;
@@ -175,48 +174,6 @@ void menuHandler::OnboardMessage()
screen->showOverlayBanner(bannerOptions);
}
static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam)
{
config.lora.region = region;
config.lora.channel_num = 0; // Reset to default channel
// Reconcile the preset with the explicitly chosen region: a preset locked to another
// region would leave config.lora invalid until applyModemConfig() repairs it with
// error/critical-error side effects — or, for the swappable EU trio, the clamp would
// flip the region right back. The user picked the region, so the preset follows it.
const RegionInfo *newRegion = getRegion(region);
if (config.lora.use_preset && !newRegion->supportsPreset(config.lora.modem_preset)) {
LOG_INFO("Preset %s not available in %s, using default %s",
DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), newRegion->name,
DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true));
config.lora.modem_preset = newRegion->getDefaultPreset();
}
if (isHam && adminModule) {
meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero;
strncpy(hamParams.call_sign, "N0CALL", sizeof(hamParams.call_sign) - 1);
strncpy(hamParams.short_name, "N0CL", sizeof(hamParams.short_name));
hamParams.tx_power = config.lora.tx_power;
hamParams.frequency = config.lora.override_frequency;
adminModule->handleSetHamMode(hamParams);
}
auto changes = SEGMENT_CONFIG;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
initRegion();
if (getEffectiveDutyCycle() < 100) {
config.lora.ignore_mqtt = true;
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG;
}
service->reloadConfig(changes);
}
void menuHandler::LoraRegionPicker(uint32_t duration)
{
static const LoraRegionOption regionOptions[] = {
@@ -275,34 +232,37 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
return;
}
// Guard: without a reboot, reconfigure() applies the region directly, so reject
// regions this node can't use up front: unrecognized codes, licensed-only regions,
// and radio hardware mismatches (2.4 GHz vs sub-GHz) — the same checks the admin
// set-config path applies, but side-effect-free: ignoring a menu selection should
// not record a critical error or notify clients. getRadio() used to catch hardware
// mismatches post-reboot only.
auto candidateLora = config.lora;
candidateLora.region = selectedRegion;
char regionErr[160];
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
LOG_WARN("Ignoring region selection: %s", regionErr);
// Guard: without a reboot, reconfigure() applies the region directly.
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora
// rather than a hardcoded check for LORA_24.
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection");
return;
}
bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
if (hamMode) {
LOG_INFO("User chose an amateur radio mode region");
pendingRegion = selectedRegion;
menuQueue = HamModeConfirm;
screen->runNow();
} else if (owner.is_licensed) {
LOG_INFO("Licensed user chose a non-ham region; prompting to revert licensed mode");
pendingRegion = selectedRegion;
menuQueue = LicensedToNormalConfirm;
screen->runNow();
} else {
applyLoraRegion(selectedRegion, false);
config.lora.region = selectedRegion;
auto changes = SEGMENT_CONFIG;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
config.lora.tx_enabled = true;
initRegion();
if (getEffectiveDutyCycle() < 100) {
config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
// Default broker is in use, so subscribe to the appropriate MQTT root topic for this region
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG;
}
service->reloadConfig(changes);
});
bannerOptions.durationMs = duration;
@@ -319,38 +279,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::hamModeConfirmMenu()
{
static const char *confirmOptions[] = {"No", "Yes"};
BannerOverlayOptions confirmBanner;
confirmBanner.message = "I confirm I am a\nlicensed amateur\nradio operator";
confirmBanner.optionsArrayPtr = confirmOptions;
confirmBanner.optionsCount = 2;
confirmBanner.bannerCallback = [](int selected) {
if (selected == 1)
applyLoraRegion(pendingRegion, true);
};
screen->showOverlayBanner(confirmBanner);
}
void menuHandler::licensedToNormalConfirmMenu()
{
static const char *confirmOptions[] = {"Keep licensed", "Revert to Normal"};
BannerOverlayOptions confirmBanner;
confirmBanner.message = "Revert licensed\nmode? This will\nre-enable encryption.";
confirmBanner.optionsArrayPtr = confirmOptions;
confirmBanner.optionsCount = 2;
confirmBanner.bannerCallback = [](int selected) {
if (selected == 1) {
owner.is_licensed = false;
config.lora.override_duty_cycle = false;
service->reloadOwner(false);
}
applyLoraRegion(pendingRegion, false);
};
screen->showOverlayBanner(confirmBanner);
}
void menuHandler::deviceRolePicker()
{
static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"};
@@ -2894,12 +2822,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case ThemeMenu:
themeMenu();
break;
case HamModeConfirm:
hamModeConfirmMenu();
break;
case LicensedToNormalConfirm:
licensedToNormalConfirmMenu();
break;
}
menuQueue = MenuNone;
}
+1 -6
View File
@@ -55,13 +55,10 @@ class menuHandler
FrameToggles,
DisplayUnits,
MessageBubblesMenu,
ThemeMenu,
HamModeConfirm,
LicensedToNormalConfirm
ThemeMenu
};
static screenMenus menuQueue;
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
static meshtastic_Config_LoRaConfig_RegionCode pendingRegion;
static void OnboardMessage();
static void LoraRegionPicker(uint32_t duration = 30000);
@@ -114,8 +111,6 @@ class menuHandler
static void messageBubblesMenu();
static void themeMenu();
static void textMessageMenu();
static void hamModeConfirmMenu();
static void licensedToNormalConfirmMenu();
private:
static void saveUIConfig();
-117
View File
@@ -66,15 +66,6 @@ uint32_t pow_of_10(uint32_t n)
return ret;
}
uint64_t pow_of_16(uint32_t n)
{
uint64_t ret = 1;
for (uint32_t i = 0; i < n; i++) {
ret *= 16ULL;
}
return ret;
}
char graphics::NotificationRenderer::alertBannerLines[MAX_LINES + 1][64] = {};
uint8_t graphics::NotificationRenderer::alertBannerLineCount = 0;
graphics::NotificationRenderer::BannerFont graphics::NotificationRenderer::alertBannerLineFonts[MAX_LINES + 1] = {};
@@ -260,12 +251,6 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
break;
case notificationTypeEnum::text_banner:
case notificationTypeEnum::selection_picker:
case notificationTypeEnum::pairing_pin:
// pairing_pin is rendered the same as text_banner — it's just a
// text banner. The split type exists only so the lockdown UI
// short-circuit in Screen.cpp can recognise the BLE pair-PIN
// banner as the one safe banner to composite over the LOCKED
// frame.
drawAlertBannerOverlay(display, state);
break;
case notificationTypeEnum::node_picker:
@@ -274,9 +259,6 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
case notificationTypeEnum::number_picker:
drawNumberPicker(display, state);
break;
case notificationTypeEnum::hex_picker:
drawHexPicker(display, state);
break;
}
}
@@ -363,105 +345,6 @@ void NotificationRenderer::drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiS
drawNotificationBox(display, state, linePointers, totalLines, 0);
}
void NotificationRenderer::drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state)
{
const char *lineStarts[MAX_LINES + 1] = {0};
uint16_t lineCount = 0;
// Parse lines
char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
lineStarts[lineCount] = alertBannerMessage;
// Find lines
while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n');
if (lineStarts[lineCount + 1][0] == '\n')
lineStarts[lineCount + 1] += 1;
lineCount++;
}
// modulo to extract
uint8_t this_digit = (currentNumber % (pow_of_16(numDigits - curSelected))) / (pow_of_16(numDigits - curSelected - 1));
// Handle input
if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS ||
inEvent.inputEvent == INPUT_BROKER_UP_LONG) {
if (this_digit == 15) {
currentNumber -= 15 * (pow_of_16(numDigits - curSelected - 1));
} else {
currentNumber += (pow_of_16(numDigits - curSelected - 1));
}
} else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS ||
inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) {
if (this_digit == 0) {
currentNumber += 15 * (pow_of_16(numDigits - curSelected - 1));
} else {
currentNumber -= (pow_of_16(numDigits - curSelected - 1));
}
} else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) {
if (inEvent.kbchar > 47 && inEvent.kbchar < 58) { // have a digit
currentNumber -= this_digit * (pow_of_16(numDigits - curSelected - 1));
currentNumber += (inEvent.kbchar - 48) * (pow_of_16(numDigits - curSelected - 1));
curSelected++;
}
} else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) {
curSelected++;
} else if (inEvent.inputEvent == INPUT_BROKER_LEFT) {
curSelected--;
} else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) &&
alertBannerUntil != 0) {
resetBanner();
return;
}
if (curSelected == static_cast<int8_t>(numDigits)) {
alertBannerCallback(currentNumber);
resetBanner();
return;
}
inEvent.inputEvent = INPUT_BROKER_NONE;
if (alertBannerMessage[0] == '\0')
return;
uint16_t totalLines = lineCount + 2;
const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation
// copy the linestarts to display to the linePointers holder
for (uint16_t i = 0; i < lineCount; i++) {
linePointers[i] = lineStarts[i];
}
std::string digits = " ";
std::string arrowPointer = " ";
for (uint16_t i = 0; i < numDigits; i++) {
// Modulo minus modulo to return just the current number
uint8_t digitValue = (currentNumber % (pow_of_16(numDigits - i))) / (pow_of_16(numDigits - i - 1));
if (digitValue < 10) {
digits += std::to_string(digitValue) + " ";
} else if (digitValue == 10) {
digits += "A ";
} else if (digitValue == 11) {
digits += "B ";
} else if (digitValue == 12) {
digits += "C ";
} else if (digitValue == 13) {
digits += "D ";
} else if (digitValue == 14) {
digits += "E ";
} else if (digitValue == 15) {
digits += "F ";
}
if (curSelected == i) {
arrowPointer += "^ ";
} else {
arrowPointer += "_ ";
}
}
linePointers[lineCount++] = digits.c_str();
linePointers[lineCount++] = arrowPointer.c_str();
drawNotificationBox(display, state, linePointers, totalLines, 0);
}
void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state)
{
static uint32_t selectedNodenum = 0;
-1
View File
@@ -42,7 +42,6 @@ class NotificationRenderer
static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1],
+4 -11
View File
@@ -79,12 +79,10 @@ static inline void transformNeedlePoint(float localX, float localY, float sinHea
outY = static_cast<int16_t>(y);
}
#if GRAPHICS_TFT_COLORING_ENABLED
static float getCompassRingAngleOffset(float heading)
{
return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f;
}
#endif
static inline StandardCompassNeedlePoints computeStandardCompassNeedlePoints(int16_t compassX, int16_t compassY,
uint16_t compassDiam, float headingRadian,
@@ -1144,16 +1142,11 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
bool origBold = config.display.heading_bold;
config.display.heading_bold = false;
if (!config.lora.tx_enabled) {
const char *txdisabled = "Transmit Disabled";
display->drawString(x, getTextPositions(display)[line], txdisabled);
// Display Region and Channel Utilization
if (currentResolution == ScreenResolution::UltraLow) {
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
} else {
// Display Region and Channel Utilization
if (currentResolution == ScreenResolution::UltraLow) {
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
} else {
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
}
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
}
char uptimeStr[32] = "";
if (currentResolution != ScreenResolution::UltraLow) {
+132
View File
@@ -0,0 +1,132 @@
#include "./EInkParallel.h"
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
#include "FastEPD.h"
using namespace NicheGraphics::Drivers;
EInkParallel::EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock)
: EInk(width, height, (UpdateTypes)(FULL | FAST)), panelType(panelType), panelClock(panelClock)
{
}
EInkParallel::~EInkParallel()
{
if (asyncRunning.load()) {
for (int i = 0; i < 50 && asyncRunning.load(); ++i)
delay(50);
if (asyncTaskHandle) {
vTaskDelete(asyncTaskHandle);
asyncTaskHandle = nullptr;
}
}
delete epaper;
}
void EInkParallel::begin(SPIClass *, uint8_t, uint8_t, uint8_t, uint8_t)
{
// Parallel panels don't use the SPI args; FastEPD owns the bus.
if (!epaper) {
epaper = new FASTEPD;
epaper->initPanel((int)panelType, panelClock);
postPanelInit();
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true);
}
}
void EInkParallel::update(uint8_t *imageData, UpdateTypes type)
{
if (!epaper)
return;
pendingType = type;
copyImageInverted(imageData);
if (type == FULL) {
// Pick CLEAR_SLOW periodically to clear ghosting.
const int clearMode = (fastRefreshCount >= FULL_SLOW_PERIOD) ? CLEAR_SLOW : CLEAR_FAST;
fastRefreshCount = 0;
if (!asyncRunning.load()) {
asyncRunning.store(true);
BaseType_t rc =
xTaskCreatePinnedToCore(asyncFullTask, "epd_full", 4096 / sizeof(StackType_t), this, 2, &asyncTaskHandle,
#if CONFIG_FREERTOS_UNICORE
0
#else
1
#endif
);
if (rc != pdPASS) {
LOG_WARN("Async full failed; running blocking");
epaper->fullUpdate(clearMode, false);
epaper->backupPlane();
asyncRunning.store(false);
asyncTaskHandle = nullptr;
return; // synchronous: nothing to poll
}
// Begin polling for completion.
beginPolling(100, 1500);
}
} else {
// FAST: synchronous partial / clipped fullUpdate. Block briefly here.
epaper->fullUpdate(CLEAR_FAST, false);
epaper->backupPlane();
fastRefreshCount++;
// No polling needed; isUpdateDone() will report done immediately.
beginPolling(10, 0);
}
}
void EInkParallel::asyncFullTask(void *param)
{
auto *self = static_cast<EInkParallel *>(param);
if (!self) {
vTaskDelete(nullptr);
return;
}
self->epaper->fullUpdate(CLEAR_FAST, false);
self->epaper->backupPlane();
self->asyncRunning.store(false);
self->asyncTaskHandle = nullptr;
vTaskDelete(nullptr);
}
bool EInkParallel::isUpdateDone()
{
return !asyncRunning.load();
}
void EInkParallel::finalizeUpdate()
{
pendingType = UpdateTypes::UNSPECIFIED;
}
// Convert a niche-format buffer (row-major, MSB-left, 1=WHITE) into FastEPD's currentBuffer
// (row-major, MSB-left, 1=BLACK). Polarity inversion only.
void EInkParallel::copyImageInverted(const uint8_t *src)
{
uint8_t *dst = epaper->currentBuffer();
if (!dst || !src)
return;
const uint16_t rowBytes = ((width - 1) / 8) + 1;
const uint32_t total = rowBytes * height;
// Mask off bits beyond the panel width in the trailing byte of each row.
const uint8_t trailingMask = (uint8_t)(0xFFu << ((rowBytes * 8) - width));
for (uint16_t y = 0; y < height; y++) {
const uint32_t base = y * rowBytes;
for (uint16_t b = 0; b < rowBytes - 1; b++) {
dst[base + b] = ~src[base + b];
}
dst[base + rowBytes - 1] = (~src[base + rowBytes - 1]) & trailingMask;
}
(void)total;
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
+69
View File
@@ -0,0 +1,69 @@
/*
Parallel-EPD niche driver, backed by FastEPD.
Used for boards with an 8-bit parallel EPD interface (e.g. LILYGO T5 S3 ePaper).
The base class signature passes SPI parameters; this driver ignores them and uses FastEPD
to drive the parallel bus directly.
Gated on NICHE_HAS_FASTEPD because FastEPD is a heavy dependency that only parallel-EPD
variants want pulled in. Variants opt in by defining NICHE_HAS_FASTEPD in their platformio.ini
and adding the FastEPD library to lib_deps.
*/
#pragma once
#include "configuration.h"
#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
#include "./EInk.h"
#include <atomic>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
class FASTEPD;
namespace NicheGraphics::Drivers
{
class EInkParallel : public EInk
{
public:
EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock = 28000000);
~EInkParallel();
// SPI parameters are unused for parallel panels.
void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override;
void update(uint8_t *imageData, UpdateTypes type) override;
FASTEPD *fastEpd() { return epaper; }
protected:
bool isUpdateDone() override;
void finalizeUpdate() override;
// Hook for boards that need to bring up GPIO expanders / power pins after FastEPD::initPanel.
virtual void postPanelInit() {}
private:
void copyImageInverted(const uint8_t *src);
static void asyncFullTask(void *param);
FASTEPD *epaper = nullptr;
uint32_t panelType;
uint32_t panelClock;
UpdateTypes pendingType = UpdateTypes::UNSPECIFIED;
std::atomic<bool> asyncRunning{false};
TaskHandle_t asyncTaskHandle = nullptr;
uint8_t fastRefreshCount = 0;
static constexpr uint8_t FULL_SLOW_PERIOD = 100;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
+49
View File
@@ -0,0 +1,49 @@
#include "./GDEH0122T61.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEH0122T61::configScanning()
{
sendCommand(0x01);
sendData(0xAF); // Scan until gate 175 (176px vertical resolution, low byte)
sendData(0x00); // high byte
sendData(0x00);
}
void GDEH0122T61::configWaveform()
{
sendCommand(0x3C);
sendData(0x05);
sendCommand(0x18);
sendData(0x80);
}
void GDEH0122T61::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEH0122T61::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 250);
case FULL:
default:
return beginPolling(100, 1500);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEH0122T61
- Manufacturer: Good Display
- Size: 1.22 inch
- Resolution: 192px x 176px
- Controller IC: SSD1681 (operating in a sub-200x200 window)
Used by: t-echo-lite.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEH0122T61 : public SSD16XX
{
private:
static constexpr uint32_t width = 192;
static constexpr uint32_t height = 176;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEH0122T61() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+57
View File
@@ -0,0 +1,57 @@
#include "./GDEQ031T10.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEQ031T10::configScanning()
{
sendCommand(0x01);
sendData(0x3F); // 319, low byte
sendData(0x01); // 319, high byte
sendData(0x00);
}
void GDEQ031T10::configWaveform()
{
sendCommand(0x3C);
sendData(0x01);
sendCommand(0x18);
sendData(0x80);
}
void GDEQ031T10::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x21);
sendData(0x00);
sendData(0x00);
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x21);
sendData(0x40);
sendData(0x00);
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEQ031T10::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 400);
case FULL:
default:
return beginPolling(100, 2500);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEQ031T10
- Manufacturer: Good Display
- Size: 3.1 inch
- Resolution: 240px x 320px
- Controller IC: SSD1677 (SSD16XX-family, larger memory range)
Used by: t-deck-pro.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEQ031T10 : public SSD16XX
{
private:
static constexpr uint32_t width = 240;
static constexpr uint32_t height = 320;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEQ031T10() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+49
View File
@@ -0,0 +1,49 @@
#include "./GDEY029T94.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEY029T94::configScanning()
{
sendCommand(0x01);
sendData(0x27); // 295, low byte
sendData(0x01); // 295, high byte
sendData(0x00);
}
void GDEY029T94::configWaveform()
{
sendCommand(0x3C);
sendData(0x05);
sendCommand(0x18);
sendData(0x80);
}
void GDEY029T94::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEY029T94::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 300);
case FULL:
default:
return beginPolling(100, 2000);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEY029T94 (also sold as GDEY029T94-V2)
- Manufacturer: Good Display
- Size: 2.9 inch
- Resolution: 128px x 296px
- Controller IC: SSD1680
Used by: esp32-s3-pico, crowpanel-esp32s3-2-epaper.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEY029T94 : public SSD16XX
{
private:
static constexpr uint32_t width = 128;
static constexpr uint32_t height = 296;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEY029T94() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+49
View File
@@ -0,0 +1,49 @@
#include "./GDEY042T81.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEY042T81::configWaveform()
{
sendCommand(0x3C);
sendData(0x01);
sendCommand(0x18);
sendData(0x80);
}
void GDEY042T81::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x21);
sendData(0x00);
sendData(0x00);
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x21);
sendData(0x40);
sendData(0x00);
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEY042T81::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 1000);
case FULL:
default:
return beginPolling(100, 3500);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+42
View File
@@ -0,0 +1,42 @@
/*
E-Ink display driver
- GDEY042T81
- Manufacturer: Good Display
- Size: 4.2 inch
- Resolution: 400px x 300px
- Controller IC: SSD1683
Used by: ME25LS01-4Y10TD_e-ink.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEY042T81 : public SSD16XX
{
private:
static constexpr uint32_t width = 400;
static constexpr uint32_t height = 300;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEY042T81() : SSD16XX(width, height, supported) {}
protected:
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+57
View File
@@ -0,0 +1,57 @@
#include "./GDEY0579T93.h"
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
using namespace NicheGraphics::Drivers;
void GDEY0579T93::configScanning()
{
sendCommand(0x01);
sendData(0x0F); // 271, low byte
sendData(0x01); // 271, high byte
sendData(0x00);
}
void GDEY0579T93::configWaveform()
{
sendCommand(0x3C);
sendData(0x01);
sendCommand(0x18);
sendData(0x80);
}
void GDEY0579T93::configUpdateSequence()
{
switch (updateType) {
case FAST:
sendCommand(0x21);
sendData(0x00);
sendData(0x00);
sendCommand(0x22);
sendData(0xFF);
break;
case FULL:
default:
sendCommand(0x21);
sendData(0x40);
sendData(0x00);
sendCommand(0x22);
sendData(0xF7);
break;
}
}
void GDEY0579T93::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(100, 2000);
case FULL:
default:
return beginPolling(150, 5000);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+43
View File
@@ -0,0 +1,43 @@
/*
E-Ink display driver
- GDEY0579T93
- Manufacturer: Good Display
- Size: 5.79 inch
- Resolution: 792px x 272px
- Controller IC: SSD1683 (extended memory range)
Used by: crowpanel-esp32s3-5-epaper.
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./SSD16XX.h"
namespace NicheGraphics::Drivers
{
class GDEY0579T93 : public SSD16XX
{
private:
static constexpr uint32_t width = 792;
static constexpr uint32_t height = 272;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
GDEY0579T93() : SSD16XX(width, height, supported) {}
protected:
void configScanning() override;
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS

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