Compare commits
219
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3482f2a1b | ||
|
|
e2fda6598c | ||
|
|
982440d21d | ||
|
|
f9fea562aa | ||
|
|
c366296ab4 | ||
|
|
a076d97eb6 | ||
|
|
32dcd90abf | ||
|
|
5b7a5b2c22 | ||
|
|
5bce26d9b7 | ||
|
|
ef734b73c7 | ||
|
|
c7748a1602 | ||
|
|
91f930d5c0 | ||
|
|
f2c5cb0a05 | ||
|
|
e10e13226d | ||
|
|
5e69bc6c3f | ||
|
|
97ec74b58a | ||
|
|
f3cb2bff78 | ||
|
|
894c5556cf | ||
|
|
2f92eb8499 | ||
|
|
8d08077412 | ||
|
|
00ec69201d | ||
|
|
4304480ca3 | ||
|
|
1747e2d8e5 | ||
|
|
622aa046f1 | ||
|
|
98e0604edf | ||
|
|
23ead5f2f1 | ||
|
|
3261c04afb | ||
|
|
a541957480 | ||
|
|
767a748188 | ||
|
|
4a1ff18f57 | ||
|
|
2a91d186eb | ||
|
|
4827498188 | ||
|
|
3a0c08b695 | ||
|
|
7bdff8ff70 | ||
|
|
35b0590408 | ||
|
|
cbddf07bc8 | ||
|
|
871194517d | ||
|
|
5a1d2b9ef4 | ||
|
|
9cd3a86938 | ||
|
|
748668b8e9 | ||
|
|
778d1ad90f | ||
|
|
75b7a7df4f | ||
|
|
8110887be2 | ||
|
|
5f734dabf9 | ||
|
|
039ad42758 | ||
|
|
593909c26b | ||
|
|
29a61dc75c | ||
|
|
eead467ce6 | ||
|
|
f3ae02c425 | ||
|
|
d9cb74e4dd | ||
|
|
7ff6641f97 | ||
|
|
f7548e7c25 | ||
|
|
b960121464 | ||
|
|
b074645586 | ||
|
|
811dd427dd | ||
|
|
1c06b702dc | ||
|
|
59b4993861 | ||
|
|
da61a0db7d | ||
|
|
8e99ffbe7e | ||
|
|
a23f923e64 | ||
|
|
0522039830 | ||
|
|
94bb21ecc7 | ||
|
|
1bcabb893b | ||
|
|
fefd424901 | ||
|
|
f63716c322 | ||
|
|
5e2ca8aed4 | ||
|
|
4553d1e0b1 | ||
|
|
784e3748e2 | ||
|
|
b11d29ff31 | ||
|
|
cdc47a2aea | ||
|
|
fcef46f4b0 | ||
|
|
bb86cf4e81 | ||
|
|
6ea0d5ebba | ||
|
|
b2bda3b07a | ||
|
|
b7a9555905 | ||
|
|
1eb860a3fc | ||
|
|
989b8620ba | ||
|
|
a0951f23c3 | ||
|
|
e19f531059 | ||
|
|
0479e40588 | ||
|
|
6c7ffa1054 | ||
|
|
f037ce2165 | ||
|
|
bc17d004ab | ||
|
|
06a6c3ee20 | ||
|
|
bfadf0c36a | ||
|
|
24c4162a75 | ||
|
|
b148fac340 | ||
|
|
4dcff7bd4f | ||
|
|
8dde4eeee1 | ||
|
|
4d4e14600c | ||
|
|
0bd8dee346 | ||
|
|
2828dbe4ca | ||
|
|
a8c8fd7002 | ||
|
|
e7c02da24b | ||
|
|
7421953e8f | ||
|
|
9306e66067 | ||
|
|
7adfc3f992 | ||
|
|
ba9cadc14d | ||
|
|
924411de59 | ||
|
|
d9195944df | ||
|
|
83a98c81f6 | ||
|
|
837637b70c | ||
|
|
7b3f58875a | ||
|
|
b2d980fc25 | ||
|
|
2ed7bba5e7 | ||
|
|
22d50fe437 | ||
|
|
cde5a08bc5 | ||
|
|
55bf8c25fc | ||
|
|
399dde0f4b | ||
|
|
66971a0a26 | ||
|
|
4c24218afb | ||
|
|
4b4914736f | ||
|
|
a6b1a69630 | ||
|
|
28e705de5c | ||
|
|
fcb9ec0c2d | ||
|
|
a4b55bc6f2 | ||
|
|
561c4f31f7 | ||
|
|
cb0abbc61c | ||
|
|
1a39e80562 | ||
|
|
b53fe7a1e7 | ||
|
|
db9fdd6794 | ||
|
|
3b4c66439d | ||
|
|
945f4780ea | ||
|
|
0e38a15d46 | ||
|
|
63bce1f01a | ||
|
|
4090d9f2b3 | ||
|
|
d50caf231b | ||
|
|
f396200d38 | ||
|
|
6c04c37294 | ||
|
|
34aa5e995b | ||
|
|
c8dac10348 | ||
|
|
aab4cd086f | ||
|
|
6208c243f9 | ||
|
|
3a87e746a8 | ||
|
|
cd04206334 | ||
|
|
2768080edf | ||
|
|
466cc4cecd | ||
|
|
fe90c49795 | ||
|
|
31418ca821 | ||
|
|
0ee5777c15 | ||
|
|
026213aab7 | ||
|
|
0cab43fb43 | ||
|
|
1341cd4078 | ||
|
|
4059202a5c | ||
|
|
d24d8806e1 | ||
|
|
a67eb15ad3 | ||
|
|
9e182a595c | ||
|
|
4587dc2d64 | ||
|
|
00fccd87f9 | ||
|
|
01bd4cfb73 | ||
|
|
125c1b7f13 | ||
|
|
d6cf67b6bc | ||
|
|
77f378dd53 | ||
|
|
16dcafa7fb | ||
|
|
4ac74edf38 | ||
|
|
69495dcd98 | ||
|
|
288d7a3e7b | ||
|
|
4990bf51e3 | ||
|
|
b2c8cbb78d | ||
|
|
8061f83704 | ||
|
|
a3b49b9028 | ||
|
|
17945884a5 | ||
|
|
1116f06139 | ||
|
|
c728cfdaf4 | ||
|
|
9322bcdb21 | ||
|
|
2f19a1d7a4 | ||
|
|
71c8143f72 | ||
|
|
222d3e6b62 | ||
|
|
2e6519bb98 | ||
|
|
726d539174 | ||
|
|
e7ee4bea18 | ||
|
|
efd2613bd7 | ||
|
|
f88bc732cc | ||
|
|
0746f8cfff | ||
|
|
0ac95e370b | ||
|
|
283ccb83d1 | ||
|
|
5319bc7c2c | ||
|
|
db694f2f24 | ||
|
|
814f1289f6 | ||
|
|
0b7556b590 | ||
|
|
f7e4ac3e43 | ||
|
|
c36ae159ed | ||
|
|
33e7f16c05 | ||
|
|
d3a86841b9 | ||
|
|
e14b8d385a | ||
|
|
8ce1a872eb | ||
|
|
abfa346630 | ||
|
|
2ffe2d829c | ||
|
|
bfaf6c6b20 | ||
|
|
d293d654a0 | ||
|
|
8384659608 | ||
|
|
a632d0133f | ||
|
|
f982b58d61 | ||
|
|
efb0b299b2 | ||
|
|
eb80d3141d | ||
|
|
3604c1255d | ||
|
|
22d63fa69c | ||
|
|
fc030d2d19 | ||
|
|
4a534f02a4 | ||
|
|
0ea408e12b | ||
|
|
644d0d4a15 | ||
|
|
f04746a928 | ||
|
|
1be2529fb9 | ||
|
|
959756abf1 | ||
|
|
c88b802e32 | ||
|
|
9f74fc11de | ||
|
|
2ef09d17b9 | ||
|
|
19d070c284 | ||
|
|
e24db2994b | ||
|
|
ac7a58cd45 | ||
|
|
53c21eb30d | ||
|
|
e51e6cad84 | ||
|
|
4890f7084f | ||
|
|
3fcbfe4370 | ||
|
|
da808cb43b | ||
|
|
82580c6798 | ||
|
|
016e68ec53 | ||
|
|
79f469ce71 | ||
|
|
0501e177e9 |
@@ -135,6 +135,99 @@ On top of authorization, any remote admin message that **mutates** state (not a
|
||||
- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast.
|
||||
- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo.
|
||||
|
||||
## NodeDB Layout (v25)
|
||||
|
||||
`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now — the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`.
|
||||
|
||||
### Slim `NodeInfoLite`
|
||||
|
||||
`UserLite` is flattened onto `NodeInfoLite` (no nested sub-message); `position` and `device_metrics` are removed entirely (tags reserved). MAC address is dropped. Long names are capped at 25 chars (`max_size:25` in `deviceonly.options`); `hw_model` and `role` are `int_size:8`. Encoded size dropped from ~166 B → ~105 B per node.
|
||||
|
||||
Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** — use the inline helpers in `src/mesh/NodeDB.h`:
|
||||
|
||||
```cpp
|
||||
nodeInfoLiteHasUser(n) // bit 5 — user fields populated
|
||||
nodeInfoLiteIsFavorite(n) // bit 3
|
||||
nodeInfoLiteIsIgnored(n) // bit 4
|
||||
nodeInfoLiteIsMuted(n) // bit 1
|
||||
nodeInfoLiteIsLicensed(n) // bit 6 — Ham mode peer
|
||||
nodeInfoLiteIsKeyManuallyVerified(n) // bit 0
|
||||
nodeInfoLiteHasIsUnmessagable(n) // bit 8 — "is_unmessagable was sent"
|
||||
nodeInfoLiteIsUnmessagable(n) // bit 7
|
||||
// via_mqtt is bit 2 (mask exposed; predicate uses the mask directly)
|
||||
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); // setter
|
||||
```
|
||||
|
||||
### Satellite stores
|
||||
|
||||
Four `std::unordered_map<NodeNum, …>` members on `NodeDB`, each gated by its own build flag:
|
||||
|
||||
| Map | Value type | Build flag |
|
||||
| ----------------- | ------------------------------- | ---------------------------------- |
|
||||
| `nodePositions` | `meshtastic_PositionLite` | `MESHTASTIC_EXCLUDE_POSITIONDB` |
|
||||
| `nodeTelemetry` | `meshtastic_DeviceMetrics` | `MESHTASTIC_EXCLUDE_TELEMETRYDB` |
|
||||
| `nodeEnvironment` | `meshtastic_EnvironmentMetrics` | `MESHTASTIC_EXCLUDE_ENVIRONMENTDB` |
|
||||
| `nodeStatus` | `meshtastic_StatusMessage` | `MESHTASTIC_EXCLUDE_STATUSDB` |
|
||||
|
||||
Defaults are ON (i.e., maps **excluded**) for STM32WL only — see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`.
|
||||
|
||||
All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** — concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception.
|
||||
|
||||
### Accessor convention
|
||||
|
||||
**Never hand out pointers into the maps.** Use the copy-out accessors on `NodeDB`:
|
||||
|
||||
```cpp
|
||||
bool copyNodePosition(NodeNum, meshtastic_PositionLite &out) const;
|
||||
bool copyNodeTelemetry(NodeNum, meshtastic_DeviceMetrics &out) const;
|
||||
bool copyNodeEnvironment(NodeNum, meshtastic_EnvironmentMetrics &out) const;
|
||||
bool copyNodeStatus(NodeNum, meshtastic_StatusMessage &out) const;
|
||||
```
|
||||
|
||||
Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate — pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these.
|
||||
|
||||
Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) — these own the lock and the eviction hooks.
|
||||
|
||||
### Eviction
|
||||
|
||||
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly.
|
||||
|
||||
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
|
||||
|
||||
There is no capability flag and no special "gradient" nonce. The **default** sync flow is:
|
||||
|
||||
1. Config / module-config / channel / metadata segments (same as before).
|
||||
2. `STATE_SEND_OWN_NODEINFO` — **our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`.
|
||||
3. `STATE_SEND_OTHER_NODEINFOS` — every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`.
|
||||
4. `STATE_SEND_FILEMANIFEST` → `STATE_SEND_COMPLETE_ID` — the phone sees `config_complete_id` and treats sync as done.
|
||||
5. `STATE_SEND_PACKETS` — live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling — any code that already updates UI on `POSITION_APP` etc. works.
|
||||
|
||||
`PhoneAPI::sendConfigComplete()` arms `replayPhase = REPLAY_PHASE_POSITIONS` for default/full sync and `SPECIAL_NONCE_ONLY_NODES`, while `SPECIAL_NONCE_ONLY_CONFIG` skips replay. The drain runs inside `STATE_SEND_PACKETS` via `popReplayPacket()`, lower priority than live traffic. When all four phases drain, `replayPhase` flips back to `REPLAY_PHASE_IDLE` and the snapshot vectors get `shrink_to_fit`ed.
|
||||
|
||||
STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets — `popReplayPacket` advances through each phase in microseconds without emitting anything.
|
||||
|
||||
Special nonces that still mean something:
|
||||
|
||||
- `SPECIAL_NONCE_ONLY_CONFIG` (69420) — skip node sync entirely, just config.
|
||||
- `SPECIAL_NONCE_ONLY_NODES` (69421) — skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain.
|
||||
|
||||
There are no other reserved nonces; everything else is a fresh random `want_config_id` from the client.
|
||||
|
||||
### v24 → v25 migration
|
||||
|
||||
The legacy migration code lives in **`src/mesh/NodeDBLegacyMigration.cpp`**, not in `NodeDB.cpp`. It owns the `meshtastic_NodeDatabase_Legacy` callback and `NodeDB::migrateLegacyNodeDatabase()`. The legacy proto descriptor is `protobufs/meshtastic/deviceonly_legacy.proto` (only included by the migration TU). The boot path peeks the file's leading version tag, runs the migration if `version < 25`, then re-saves in v25 layout. The legacy descriptor is scheduled for removal once `DEVICESTATE_MIN_VER` is bumped.
|
||||
|
||||
### Read-site rules of thumb
|
||||
|
||||
- Never `node->position.X` / `node->device_metrics.X` — those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`.
|
||||
- Never `node->user.long_name` — `long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`.
|
||||
- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` — use the bitfield helpers.
|
||||
- Never assume `nodeDB->getMeshNode(num)->position.time` — call `copyNodePosition` and check the return.
|
||||
- Don't lock `satelliteMutex` yourself in renderer code; the copy-out accessors already do.
|
||||
|
||||
Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) — bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -4,9 +4,14 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# trunk-ignore(checkov/CKV_GHA_7)
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
arch:
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
@@ -15,32 +20,18 @@ on:
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
# find-target:
|
||||
# type: boolean
|
||||
# default: true
|
||||
# description: 'Find the available targets'
|
||||
description: Choose an arch to limit the search, or 'all' to search all architectures.
|
||||
default: all
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
find-targets:
|
||||
if: ${{ inputs.target == '' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
- esp32c6
|
||||
- nrf52840
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
- all
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -51,14 +42,37 @@ jobs:
|
||||
- run: pip install -U platformio
|
||||
- name: Generate matrix
|
||||
id: jsonStep
|
||||
env:
|
||||
BUILDTARGET: ${{ inputs.target }}
|
||||
MATRIXARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Targets:" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$BUILDTARGET" = "" ]; then
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "We build this one:" >> $GITHUB_STEP_SUMMARY
|
||||
ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform')
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "$ARCH" == "" ]]; then
|
||||
echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY
|
||||
echo "arch=$ARCH" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
outputs:
|
||||
arch: ${{ steps.jsonStep.outputs.arch }}
|
||||
|
||||
version:
|
||||
if: ${{ inputs.target != '' }}
|
||||
@@ -78,12 +92,12 @@ jobs:
|
||||
|
||||
build:
|
||||
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
||||
needs: [version]
|
||||
needs: [version, find-targets]
|
||||
uses: ./.github/workflows/build_firmware.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.long }}
|
||||
pio_env: ${{ inputs.target }}
|
||||
platform: ${{ inputs.arch }}
|
||||
platform: ${{ needs.find-targets.outputs.arch }}
|
||||
|
||||
gather-artifacts:
|
||||
permissions:
|
||||
|
||||
@@ -333,6 +333,7 @@ jobs:
|
||||
prerelease: true
|
||||
name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
|
||||
tag_name: v${{ needs.version.outputs.long }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
body: ${{ steps.release_notes.outputs.notes }}
|
||||
|
||||
- name: Download source deb
|
||||
|
||||
@@ -86,7 +86,13 @@ jobs:
|
||||
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
||||
|
||||
- name: PlatformIO Tests
|
||||
run: platformio test -e coverage -v --junit-output-path testreport.xml
|
||||
run: |
|
||||
set -o pipefail
|
||||
# Filter out SKIPPED summary rows for hardware variants that can't run on the
|
||||
# native host. They flood the log and make it harder to spot real failures.
|
||||
# The JUnit XML is written directly to testreport.xml before the pipe, so
|
||||
# the test artifact is unaffected.
|
||||
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
|
||||
|
||||
- name: Save test results
|
||||
if: always() # run this step even if previous step failed
|
||||
|
||||
+10
@@ -47,6 +47,10 @@ data/boot/logo.*
|
||||
managed_components/*
|
||||
arduino-lib-builder*
|
||||
dependencies.lock
|
||||
|
||||
# JLink / RTT debug artifacts (nRF SoCs)
|
||||
flash.jlink
|
||||
rtt_*.txt
|
||||
idf_component.yml
|
||||
CMakeLists.txt
|
||||
/sdkconfig.*
|
||||
@@ -56,3 +60,9 @@ CMakeLists.txt
|
||||
.python3
|
||||
.claude/scheduled_tasks.lock
|
||||
userPrefs.jsonc.mcp-session-bak
|
||||
|
||||
# Fake-NodeDB fixture pipeline (bin/regen-fake-nodedbs.sh)
|
||||
# JSONL seeds are committed (test/fixtures/nodedb/seed_v25_*.jsonl);
|
||||
# compiled .proto outputs are ephemeral build artifacts.
|
||||
build/fixtures/
|
||||
bin/_generated/
|
||||
|
||||
@@ -34,6 +34,13 @@ lint:
|
||||
- linters: [ALL]
|
||||
paths:
|
||||
- bin/**
|
||||
# Fake-NodeDB fixture JSONL files contain deterministic synthetic
|
||||
# public_key_hex (64-char hex) values that gitleaks misidentifies as
|
||||
# generic-api-key. These are not secrets — they're test fixtures
|
||||
# produced by bin/gen-fake-nodedb-seed.py with a fixed RNG seed.
|
||||
- linters: [gitleaks]
|
||||
paths:
|
||||
- test/fixtures/nodedb/seed_v25_*.jsonl
|
||||
runtimes:
|
||||
enabled:
|
||||
- python@3.14.4
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-process protoc-generated Python files to live under a local namespace.
|
||||
|
||||
Called by bin/regen-py-protos.sh. Walks the generated *_pb2.py files in the
|
||||
target directory and rewrites every `meshtastic` reference (imports, dotted
|
||||
attribute access) to use the new namespace (e.g., `meshtastic_v25`).
|
||||
|
||||
Why: the .proto files declare `package meshtastic;`, so protoc emits
|
||||
`from meshtastic import mesh_pb2 as ...` lines. That would shadow the PyPI
|
||||
`meshtastic` package which other parts of the mcp-server depend on. Renaming
|
||||
to a local namespace keeps both available.
|
||||
|
||||
Usage:
|
||||
_rewrite_proto_namespace.py <generated_dir> <new_namespace>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def rewrite(dir_path: pathlib.Path, new_ns: str) -> int:
|
||||
# Standard protoc import forms:
|
||||
# from meshtastic.X_pb2 import ... (rare, for direct symbol pulls)
|
||||
# from meshtastic import X_pb2 as ... (common, the cross-file ref)
|
||||
# import meshtastic.X_pb2 (also possible)
|
||||
pattern_dotted_from = re.compile(r"^from meshtastic\.", re.MULTILINE)
|
||||
pattern_bare_from = re.compile(r"^from meshtastic import ", re.MULTILINE)
|
||||
pattern_dotted_import = re.compile(r"^import meshtastic\.", re.MULTILINE)
|
||||
|
||||
count = 0
|
||||
for p in dir_path.glob("*.py"):
|
||||
text = p.read_text(encoding="utf-8")
|
||||
new = pattern_dotted_from.sub(f"from {new_ns}.", text)
|
||||
new = pattern_bare_from.sub(f"from {new_ns} import ", new)
|
||||
new = pattern_dotted_import.sub(f"import {new_ns}.", new)
|
||||
# NOTE: we deliberately leave `meshtastic/X.proto` source-filename
|
||||
# references inside descriptor strings alone. The descriptor pool is
|
||||
# keyed by source filename (independent of Python package layout), so
|
||||
# those don't collide with the PyPI package's descriptors.
|
||||
if new != text:
|
||||
p.write_text(new, encoding="utf-8")
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("usage: _rewrite_proto_namespace.py <generated_dir> <new_namespace>", file=sys.stderr)
|
||||
return 2
|
||||
dir_path = pathlib.Path(argv[0])
|
||||
new_ns = argv[1]
|
||||
if not dir_path.is_dir():
|
||||
print(f"directory not found: {dir_path}", file=sys.stderr)
|
||||
return 2
|
||||
n = rewrite(dir_path, new_ns)
|
||||
print(f"rewrote {n} file(s) in {dir_path} → namespace {new_ns}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+1
-1
@@ -38,4 +38,4 @@ cp bin/device-install.* $OUTDIR/
|
||||
cp bin/device-update.* $OUTDIR/
|
||||
|
||||
echo "Copying manifest"
|
||||
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true
|
||||
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, HIGH, LOW]
|
||||
MODE_TX: [HIGH, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
MODE_TX_HF: [LOW, LOW, LOW]
|
||||
MODE_GNSS: [LOW, LOW, HIGH]
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins:
|
||||
- DIO5
|
||||
- DIO6
|
||||
MODE_STBY:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_RX:
|
||||
- HIGH
|
||||
- LOW
|
||||
MODE_TX:
|
||||
- HIGH
|
||||
- HIGH
|
||||
MODE_TX_HP:
|
||||
- LOW
|
||||
- HIGH
|
||||
MODE_TX_HF:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_GNSS:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_WIFI:
|
||||
- LOW
|
||||
- LOW
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, LOW, LOW]
|
||||
MODE_TX: [LOW, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
# MODE_TX_HF: []
|
||||
# MODE_GNSS: []
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
Executable
+439
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic seed-data generator for the fake NodeDB fixture pipeline.
|
||||
|
||||
Writes a JSONL file describing N fake-but-realistic Meshtastic peers.
|
||||
The output is hand-editable and committed; a sibling compile step
|
||||
(bin/seed-json-to-proto.py) turns it into a binary `meshtastic_NodeDatabase`
|
||||
v25 protobuf with fresh "now-relative" timestamps.
|
||||
|
||||
Determinism contract:
|
||||
Same --seed -> byte-identical JSONL output, regardless of wall clock.
|
||||
All timestamps are stored as `*_offset_sec` (seconds before "now"); the
|
||||
compile step resolves them to absolute epochs at compile time.
|
||||
|
||||
Structural fields covered:
|
||||
* NodeInfoLite header: num, long_name, short_name, hw_model, role,
|
||||
public_key, snr, channel, hops_away, next_hop, bitfield flags
|
||||
* PositionLite: lat/long Gaussian around --centroid, altitude, source
|
||||
* DeviceMetrics: battery/voltage/util/uptime
|
||||
* EnvironmentMetrics: temp/humidity/pressure/iaq
|
||||
* StatusMessage: error_code (usually zero)
|
||||
|
||||
Active-board allow-list:
|
||||
hw_model values are restricted to the intersection of
|
||||
(a) variants with `custom_meshtastic_support_level = 1` in
|
||||
variants/*/*/platformio.ini, AND
|
||||
(b) values present in the `HardwareModel` enum in mesh.proto.
|
||||
See HW_MODEL_WEIGHTS below. Deprecated boards (legacy TLORA / Heltec V1-2 /
|
||||
classic TBEAM / TBEAM_V0P7 / Nano G1 / etc.) and fuzzer-only sentinels
|
||||
(PORTDUINO, ANDROID_SIM, DIY_V1, ...) are excluded.
|
||||
|
||||
Active-role allow-list:
|
||||
Excludes ROUTER_CLIENT (deprecated v2.3.15) and REPEATER (deprecated v2.7.11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import random
|
||||
import sys
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Active-board allow-list (intersection of tier-1 variants + HardwareModel enum).
|
||||
# Refresh by running:
|
||||
# for f in $(find variants -name 'platformio.ini' | xargs grep -lE 'custom_meshtastic_support_level = 1'); do
|
||||
# grep custom_meshtastic_hw_model_slug $f | awk -F= '{print $2}' | tr -d ' ';
|
||||
# done | sort -u | comm -12 - <(python3 -c "from meshtastic.protobuf.mesh_pb2 import HardwareModel; print('\\n'.join(HardwareModel.keys()))" | sort)
|
||||
# --------------------------------------------------------------------------
|
||||
HW_MODEL_WEIGHTS: dict[str, float] = {
|
||||
"HELTEC_V3": 14.0,
|
||||
"T_DECK": 9.0,
|
||||
"HELTEC_V4": 8.0,
|
||||
"RAK4631": 8.0,
|
||||
"HELTEC_MESH_POCKET": 6.0,
|
||||
"TRACKER_T1000_E": 5.0,
|
||||
"HELTEC_MESH_NODE_T114": 5.0,
|
||||
"T_DECK_PRO": 5.0,
|
||||
"LILYGO_TBEAM_S3_CORE": 4.0,
|
||||
"HELTEC_WIRELESS_PAPER": 4.0,
|
||||
"HELTEC_WSL_V3": 3.0,
|
||||
"T_ECHO": 3.0,
|
||||
"HELTEC_WIRELESS_TRACKER": 3.0,
|
||||
"HELTEC_WIRELESS_TRACKER_V2": 2.0,
|
||||
"HELTEC_VISION_MASTER_E290": 2.0,
|
||||
"HELTEC_MESH_SOLAR": 2.0,
|
||||
"SEEED_WIO_TRACKER_L1": 2.0,
|
||||
"T_LORA_PAGER": 1.5,
|
||||
"HELTEC_VISION_MASTER_E213": 1.5,
|
||||
"T_ECHO_PLUS": 1.0,
|
||||
"MUZI_BASE": 1.0,
|
||||
"WISMESH_TAP_V2": 1.0,
|
||||
"THINKNODE_M2": 1.0,
|
||||
"THINKNODE_M5": 1.0,
|
||||
"TLORA_T3_S3": 1.0,
|
||||
# Long tail (uniform low weight across remaining tier-1 boards):
|
||||
"HELTEC_V4_R8": 0.3,
|
||||
"HELTEC_VISION_MASTER_T190": 0.3,
|
||||
"HELTEC_HT62": 0.3,
|
||||
"HELTEC_MESH_NODE_T096": 0.3,
|
||||
"M5STACK_C6L": 0.3,
|
||||
"MINI_EPAPER_S3": 0.3,
|
||||
"MUZI_R1_NEO": 0.3,
|
||||
"NOMADSTAR_METEOR_PRO": 0.3,
|
||||
"RAK3312": 0.3,
|
||||
"RAK3401": 0.3,
|
||||
"SEEED_SOLAR_NODE": 0.3,
|
||||
"SEEED_WIO_TRACKER_L1_EINK": 0.3,
|
||||
"SENSECAP_INDICATOR": 0.3,
|
||||
"TBEAM_1_WATT": 0.3,
|
||||
"THINKNODE_M1": 0.3,
|
||||
"THINKNODE_M3": 0.3,
|
||||
"THINKNODE_M6": 0.3,
|
||||
"T_ECHO_LITE": 0.3,
|
||||
"WISMESH_TAG": 0.3,
|
||||
"WISMESH_TAP": 0.3,
|
||||
"XIAO_NRF52_KIT": 0.3,
|
||||
"CROWPANEL": 0.3,
|
||||
}
|
||||
|
||||
# Non-deprecated roles only.
|
||||
ROLE_WEIGHTS: dict[str, float] = {
|
||||
"CLIENT": 75.0,
|
||||
"CLIENT_MUTE": 5.0,
|
||||
"ROUTER": 7.0,
|
||||
"TRACKER": 3.0,
|
||||
"SENSOR": 2.0,
|
||||
"CLIENT_HIDDEN": 2.0,
|
||||
"ROUTER_LATE": 2.0,
|
||||
"CLIENT_BASE": 2.0,
|
||||
"TAK": 1.0,
|
||||
"TAK_TRACKER": 0.5,
|
||||
"LOST_AND_FOUND": 0.5,
|
||||
}
|
||||
|
||||
# Name pools — 60 firsts × 60 lasts = 3600 combinations.
|
||||
FIRSTS = [
|
||||
"Quick", "Brave", "Silent", "Wild", "Lone", "Bright", "Red", "Blue",
|
||||
"Green", "Black", "White", "Iron", "Steel", "Copper", "Silver", "Gold",
|
||||
"Stone", "River", "Forest", "Mountain", "Canyon", "Desert", "Storm", "Sky",
|
||||
"Solar", "Lunar", "Dawn", "Dusk", "Misty", "Frosty", "Sunny", "Shady",
|
||||
"Happy", "Sleepy", "Drowsy", "Sneaky", "Sharp", "Smooth", "Rough", "Loud",
|
||||
"Soft", "Slow", "Fast", "Tall", "Short", "Old", "New", "Tiny",
|
||||
"Giant", "Hidden", "Lost", "Found", "Wandering", "Roving", "Drifting", "Floating",
|
||||
"Burning", "Frozen", "Whispering", "Howling",
|
||||
]
|
||||
LASTS = [
|
||||
"Phoenix", "Lion", "Bear", "Wolf", "Hawk", "Eagle", "Fox", "Lynx",
|
||||
"Cougar", "Coyote", "Raven", "Owl", "Crow", "Falcon", "Heron", "Crane",
|
||||
"Otter", "Badger", "Bison", "Elk", "Moose", "Stag", "Doe", "Hare",
|
||||
"Marmot", "Mole", "Beaver", "Squirrel", "Mustang", "Bronco", "Pony", "Colt",
|
||||
"Cobra", "Viper", "Mamba", "Adder", "Gecko", "Iguana", "Tortoise", "Turtle",
|
||||
"Salmon", "Trout", "Bass", "Pike", "Shark", "Whale", "Dolphin", "Seal",
|
||||
"Cactus", "Yucca", "Sage", "Juniper", "Pine", "Cedar", "Aspen", "Oak",
|
||||
"Bluff", "Mesa", "Arroyo", "Ridge",
|
||||
]
|
||||
|
||||
# Brief callsign pool for licensed-looking suffixes.
|
||||
CALLSIGN_PREFIXES = ["KX", "WD", "N5", "KE", "AB", "W5", "K1", "KQ", "AE", "NM"]
|
||||
|
||||
# Only emojis that fit in 4 UTF-8 bytes (no variation selectors). short_name's
|
||||
# nanopb max_size:5 (incl. NUL) limits content to 4 bytes. ❄️ / ☀️ would be
|
||||
# 6 bytes due to U+FE0F variation selector — explicitly excluded.
|
||||
EMOJI_SHORTNAMES = ["🦊", "🐺", "🦅", "🐢", "🌵", "🔥", "🌙",
|
||||
"🌊", "🗻", "🌲", "🦌", "🐝", "🦂", "🦉",
|
||||
"🦇", "🦋"]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
NUM_RESERVED = 4 # firmware reserves 0..3 (per NodeDB constants)
|
||||
NUM_MAX_EXCLUSIVE = 0x80000000 # restrict to positive int32 range for readability
|
||||
|
||||
|
||||
def _weighted_choice(rng: random.Random, weights: dict[str, float]) -> str:
|
||||
"""Deterministic weighted pick. Uses sorted keys so dict order is fixed."""
|
||||
keys = sorted(weights.keys())
|
||||
totals = [weights[k] for k in keys]
|
||||
return rng.choices(keys, weights=totals, k=1)[0]
|
||||
|
||||
|
||||
def _gen_long_name(rng: random.Random, is_licensed: bool) -> str:
|
||||
base = f"{rng.choice(FIRSTS)} {rng.choice(LASTS)}"
|
||||
if is_licensed:
|
||||
prefix = rng.choice(CALLSIGN_PREFIXES)
|
||||
# Two trailing alpha chars after the digit; keep within 25 - len(base) - 1
|
||||
suffix = f" {prefix}{rng.randint(0,9)}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}"
|
||||
# nanopb max_size:25 means C string fits 24 bytes + NUL.
|
||||
if len(base) + len(suffix) <= 24:
|
||||
base = base + suffix
|
||||
# Hard cap to 24 chars (nanopb max_size:25 minus NUL).
|
||||
return base[:24]
|
||||
|
||||
|
||||
def _gen_short_name(rng: random.Random, long_name: str) -> str:
|
||||
# 10% emoji-only short_name
|
||||
if rng.random() < 0.10:
|
||||
return rng.choice(EMOJI_SHORTNAMES)
|
||||
first_char = long_name[0].upper() if long_name else "X"
|
||||
alphanums = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return first_char + "".join(rng.choices(alphanums, k=3))
|
||||
|
||||
|
||||
def _gen_hops_away(rng: random.Random) -> int:
|
||||
# Geometric-ish: 0→55%, 1→25%, 2→12%, 3→5%, 4→2%, 5+→1%
|
||||
r = rng.random()
|
||||
if r < 0.55:
|
||||
return 0
|
||||
if r < 0.80:
|
||||
return 1
|
||||
if r < 0.92:
|
||||
return 2
|
||||
if r < 0.97:
|
||||
return 3
|
||||
if r < 0.99:
|
||||
return 4
|
||||
return rng.randint(5, 7)
|
||||
|
||||
|
||||
def _gen_position(
|
||||
rng: random.Random,
|
||||
centroid_lat: float,
|
||||
centroid_lon: float,
|
||||
spread_km: float,
|
||||
last_heard_offset_sec: int,
|
||||
) -> dict:
|
||||
# 1 deg ≈ 111 km at the equator; we use this as a flat approximation.
|
||||
lat = centroid_lat + rng.gauss(0.0, spread_km / 111.0)
|
||||
lon = centroid_lon + rng.gauss(0.0, spread_km / 111.0)
|
||||
altitude = max(0, round(rng.gauss(1376.0, 250.0))) # T or C valley floor + relief
|
||||
# Position was reported up to 300s before last_heard.
|
||||
time_offset_sec = last_heard_offset_sec + rng.randint(0, 300)
|
||||
return {
|
||||
"latitude": round(lat, 6),
|
||||
"longitude": round(lon, 6),
|
||||
"altitude": altitude,
|
||||
"time_offset_sec": time_offset_sec,
|
||||
"location_source": "LOC_INTERNAL",
|
||||
}
|
||||
|
||||
|
||||
def _gen_telemetry(rng: random.Random) -> dict:
|
||||
# 5% plugged-in (battery_level == 101); rest uniform [10..100].
|
||||
if rng.random() < 0.05:
|
||||
battery_level = 101
|
||||
voltage = 4.20
|
||||
else:
|
||||
battery_level = rng.randint(10, 100)
|
||||
voltage = round(3.3 + (battery_level / 100.0) * 0.9, 3)
|
||||
# Beta distributions for low/right-skewed metrics; randomly draw via gammavariate.
|
||||
def _beta(a: float, b: float) -> float:
|
||||
x = rng.gammavariate(a, 1.0)
|
||||
y = rng.gammavariate(b, 1.0)
|
||||
return x / (x + y)
|
||||
channel_utilization = round(_beta(2.0, 15.0) * 100.0, 2)
|
||||
air_util_tx = round(_beta(1.5, 20.0) * 10.0, 3)
|
||||
uptime_seconds = int(rng.expovariate(1.0 / 86400.0))
|
||||
return {
|
||||
"battery_level": battery_level,
|
||||
"voltage": voltage,
|
||||
"channel_utilization": channel_utilization,
|
||||
"air_util_tx": air_util_tx,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
}
|
||||
|
||||
|
||||
def _gen_environment(rng: random.Random) -> dict:
|
||||
return {
|
||||
"temperature": round(rng.gauss(22.0, 8.0), 2),
|
||||
"relative_humidity": round(min(100.0, max(0.0, rng.gauss(55.0, 20.0))), 2),
|
||||
"barometric_pressure": round(rng.gauss(1013.0, 8.0), 2),
|
||||
"iaq": int(min(500, max(0, round(rng.gauss(50.0, 30.0))))),
|
||||
}
|
||||
|
||||
|
||||
def _gen_status(rng: random.Random) -> dict:
|
||||
# `StatusMessage` (mesh.proto:1445) has a single free-form `string status`.
|
||||
# Most peers report a healthy short status; occasional alert string.
|
||||
healthy = ["OK", "online", "active", "running", "ready", "nominal"]
|
||||
alert = ["low-batt", "no-gps", "weak-signal", "rebooted", "offline-soon"]
|
||||
if rng.random() < 0.92:
|
||||
return {"status": rng.choice(healthy)}
|
||||
return {"status": rng.choice(alert)}
|
||||
|
||||
|
||||
def _gen_node(
|
||||
rng: random.Random,
|
||||
num: int,
|
||||
centroid_lat: float,
|
||||
centroid_lon: float,
|
||||
spread_km: float,
|
||||
coverage: dict[str, float],
|
||||
last_heard_mean_sec: int,
|
||||
last_heard_max_sec: int,
|
||||
) -> dict:
|
||||
is_licensed = rng.random() < 0.05
|
||||
long_name = _gen_long_name(rng, is_licensed)
|
||||
short_name = _gen_short_name(rng, long_name)
|
||||
hw_model = _weighted_choice(rng, HW_MODEL_WEIGHTS)
|
||||
role = _weighted_choice(rng, ROLE_WEIGHTS)
|
||||
has_public_key = rng.random() < 0.92
|
||||
public_key_hex = (
|
||||
"".join(f"{rng.randint(0,255):02x}" for _ in range(32)) if has_public_key else ""
|
||||
)
|
||||
snr = round(max(-20.0, min(12.0, rng.gauss(6.0, 4.0))), 2)
|
||||
channel = 0 if rng.random() < 0.90 else rng.randint(1, 7)
|
||||
hops_away = _gen_hops_away(rng)
|
||||
next_hop = rng.randint(0, 255) if hops_away > 0 else 0
|
||||
last_heard_offset_sec = int(min(rng.expovariate(1.0 / last_heard_mean_sec), last_heard_max_sec))
|
||||
|
||||
bitfield = {
|
||||
"has_user": True,
|
||||
"is_favorite": rng.random() < 0.08,
|
||||
"is_muted": rng.random() < 0.03,
|
||||
"via_mqtt": rng.random() < 0.12,
|
||||
"is_ignored": rng.random() < 0.01,
|
||||
"is_licensed": is_licensed,
|
||||
"has_is_unmessagable": True,
|
||||
"is_unmessagable": rng.random() < 0.02,
|
||||
"is_key_manually_verified": rng.random() < 0.04,
|
||||
}
|
||||
|
||||
node: dict = {
|
||||
"num": f"0x{num:08x}",
|
||||
"long_name": long_name,
|
||||
"short_name": short_name,
|
||||
"hw_model": hw_model,
|
||||
"role": role,
|
||||
"public_key_hex": public_key_hex,
|
||||
"snr": snr,
|
||||
"channel": channel,
|
||||
"hops_away": hops_away,
|
||||
"next_hop": next_hop,
|
||||
"last_heard_offset_sec": last_heard_offset_sec,
|
||||
"bitfield": bitfield,
|
||||
"position": (
|
||||
_gen_position(rng, centroid_lat, centroid_lon, spread_km, last_heard_offset_sec)
|
||||
if rng.random() < coverage["position"]
|
||||
else None
|
||||
),
|
||||
"telemetry": _gen_telemetry(rng) if rng.random() < coverage["telemetry"] else None,
|
||||
"environment": _gen_environment(rng) if rng.random() < coverage["environment"] else None,
|
||||
"status": _gen_status(rng) if rng.random() < coverage["status"] else None,
|
||||
}
|
||||
return node
|
||||
|
||||
|
||||
def _parse_my_node_num(s: str | None) -> int | None:
|
||||
if s is None:
|
||||
return None
|
||||
s = s.strip()
|
||||
if s.startswith("0x") or s.startswith("0X"):
|
||||
return int(s, 16)
|
||||
return int(s)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Deterministic JSONL seed for the fake NodeDB fixture.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--count", type=int, required=True, help="Number of fake nodes to emit.")
|
||||
p.add_argument("--seed", type=int, required=True, help="Deterministic seed.")
|
||||
p.add_argument("--out", required=True, help="Output JSONL path.")
|
||||
p.add_argument(
|
||||
"--centroid",
|
||||
default="33.1284,-107.2528",
|
||||
help="LAT,LON centroid (default: Truth or Consequences, NM).",
|
||||
)
|
||||
p.add_argument("--spread-km", type=float, default=60.0, help="Gaussian std-dev in km.")
|
||||
p.add_argument("--position-coverage", type=float, default=0.85)
|
||||
p.add_argument("--telemetry-coverage", type=float, default=0.70)
|
||||
p.add_argument("--environment-coverage", type=float, default=0.25)
|
||||
p.add_argument("--status-coverage", type=float, default=0.40)
|
||||
p.add_argument("--my-node-num", default=None, help="Exclude this NodeNum from generated set (hex or dec).")
|
||||
p.add_argument("--last-heard-mean-sec", type=int, default=3600)
|
||||
p.add_argument("--last-heard-max-sec", type=int, default=7 * 86400)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.count <= 0:
|
||||
print("--count must be positive", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
centroid_lat, centroid_lon = (float(s) for s in args.centroid.split(","))
|
||||
except ValueError:
|
||||
print(f"--centroid must be LAT,LON; got {args.centroid!r}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
my_node_num = _parse_my_node_num(args.my_node_num)
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
|
||||
# 1) Generate a unique deterministic set of NodeNums.
|
||||
nums: set[int] = set()
|
||||
while len(nums) < args.count:
|
||||
n = rng.randrange(NUM_RESERVED, NUM_MAX_EXCLUSIVE)
|
||||
if my_node_num is not None and n == my_node_num:
|
||||
continue
|
||||
nums.add(n)
|
||||
ordered_nums = sorted(nums) # sort to fix output order independent of set hash
|
||||
|
||||
# 2) Per-node generation (in num order, single RNG continues).
|
||||
coverage = {
|
||||
"position": args.position_coverage,
|
||||
"telemetry": args.telemetry_coverage,
|
||||
"environment": args.environment_coverage,
|
||||
"status": args.status_coverage,
|
||||
}
|
||||
nodes = [
|
||||
_gen_node(
|
||||
rng,
|
||||
n,
|
||||
centroid_lat,
|
||||
centroid_lon,
|
||||
args.spread_km,
|
||||
coverage,
|
||||
args.last_heard_mean_sec,
|
||||
args.last_heard_max_sec,
|
||||
)
|
||||
for n in ordered_nums
|
||||
]
|
||||
|
||||
# 3) Write JSONL.
|
||||
out_path = pathlib.Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# `generated_at_iso` is informational; it does NOT affect determinism because
|
||||
# we derive it from the seed, not from wall clock. (Same seed -> same string.)
|
||||
generated_at = _dt.datetime.fromtimestamp(args.seed, tz=_dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
meta = {
|
||||
"_meta": {
|
||||
"version": 25,
|
||||
"seed": args.seed,
|
||||
"count": args.count,
|
||||
"centroid": [centroid_lat, centroid_lon],
|
||||
"spread_km": args.spread_km,
|
||||
"generated_at_iso": generated_at,
|
||||
"my_node_num_excluded": (None if my_node_num is None else f"0x{my_node_num:08x}"),
|
||||
"coverage": coverage,
|
||||
"last_heard_mean_sec": args.last_heard_mean_sec,
|
||||
"last_heard_max_sec": args.last_heard_max_sec,
|
||||
}
|
||||
}
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
# `ensure_ascii=False` so emoji short_names survive. `sort_keys=True` for
|
||||
# determinism (insertion order varies by Python version otherwise).
|
||||
f.write(json.dumps(meta, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
for node in nodes:
|
||||
f.write(json.dumps(node, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
print(f"wrote {args.count} nodes to {out_path} ({out_path.stat().st_size} bytes)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -293,9 +293,12 @@ if ("HAS_TFT", 1) in env.get("CPPDEFINES", []):
|
||||
board_arch = infer_architecture(env.BoardConfig())
|
||||
should_skip_manifest = board_arch is None
|
||||
|
||||
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
|
||||
mtjson_deps = [] if should_skip_manifest else ["buildprog"]
|
||||
if not should_skip_manifest and platform.name == "espressif32":
|
||||
# Most platforms can generate the manifest as part of the default 'buildprog' target.
|
||||
# Typically this passes success/failure properly.
|
||||
mtjson_deps = ["buildprog"]
|
||||
if platform.name == "espressif32":
|
||||
# On ESP32, we need to explicitly depend upon the binary to prevent fake-success upon failure.
|
||||
mtjson_deps = ["$BUILD_DIR/${PROGNAME}.bin"]
|
||||
# Build littlefs image as part of mtjson target
|
||||
# Equivalent to `pio run -t buildfs`
|
||||
target_lfs = env.DataToBin(
|
||||
@@ -309,7 +312,8 @@ if should_skip_manifest:
|
||||
|
||||
env.AddCustomTarget(
|
||||
name="mtjson",
|
||||
dependencies=mtjson_deps,
|
||||
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
|
||||
dependencies=[],
|
||||
actions=[skip_manifest],
|
||||
title="Meshtastic Manifest (skipped)",
|
||||
description="mtjson generation is skipped for native environments",
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate the fake-NodeDB fixtures: produces 250 / 500 / 1000 / 2000-node
|
||||
# JSONL seed files + their compiled v25 protobufs.
|
||||
#
|
||||
# Layout:
|
||||
# test/fixtures/nodedb/seed_v25_<N>.jsonl — COMMITTED, hand-editable.
|
||||
# build/fixtures/nodedb/nodes_v25_<N>.proto — .gitignored, build artifact.
|
||||
# Drop into /prefs/nodes.proto.
|
||||
#
|
||||
# Daily use: ./bin/regen-fake-nodedbs.sh
|
||||
# - Recompiles protos from committed seeds (fresh wall-clock timestamps).
|
||||
# Intentional seed bump: REGEN_SEEDS=yes ./bin/regen-fake-nodedbs.sh
|
||||
# - Overwrites the committed JSONL files with freshly-seeded data.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 1) Make sure the Python protobuf bindings exist (in-tree generation; .gitignored).
|
||||
if [[ ! -d bin/_generated/meshtastic ]]; then
|
||||
echo "regenerating Python protobuf bindings (one-time)..."
|
||||
./bin/regen-py-protos.sh
|
||||
fi
|
||||
|
||||
# 2) Pick a Python interpreter that has the meshtastic deps installed.
|
||||
# Prefer the mcp-server venv (most likely to be set up by the operator).
|
||||
PY="python3"
|
||||
for cand in mcp-server/.venv/bin/python3 .venv/bin/python3; do
|
||||
if [[ -x "$cand" ]]; then
|
||||
PY="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 3) Pinned seeds per size — bump only when you intentionally want different
|
||||
# structural data committed. Parallel arrays so the script works on
|
||||
# macOS bash 3.2 (no `declare -A`).
|
||||
SIZES=(250 500 1000 2000)
|
||||
SEEDS=(20260511 20260512 20260513 20260514)
|
||||
|
||||
REGEN_SEEDS="${REGEN_SEEDS:-no}"
|
||||
|
||||
mkdir -p build/fixtures/nodedb test/fixtures/nodedb
|
||||
|
||||
for i in 0 1 2 3; do
|
||||
n="${SIZES[$i]}"
|
||||
seed="${SEEDS[$i]}"
|
||||
jsonl=$(printf "test/fixtures/nodedb/seed_v25_%04d.jsonl" "$n")
|
||||
proto=$(printf "build/fixtures/nodedb/nodes_v25_%04d.proto" "$n")
|
||||
|
||||
if [[ "$REGEN_SEEDS" == "yes" || ! -f "$jsonl" ]]; then
|
||||
$PY bin/gen-fake-nodedb-seed.py \
|
||||
--count "$n" \
|
||||
--seed "$seed" \
|
||||
--out "$jsonl" \
|
||||
--centroid 33.1284,-107.2528 \
|
||||
--spread-km 60 \
|
||||
--position-coverage 0.85 \
|
||||
--telemetry-coverage 0.70 \
|
||||
--environment-coverage 0.25 \
|
||||
--status-coverage 0.40
|
||||
echo " seed: $jsonl ($(wc -c < "$jsonl") bytes)"
|
||||
fi
|
||||
|
||||
$PY bin/seed-json-to-proto.py --in "$jsonl" --out "$proto"
|
||||
echo " proto: $proto ($(wc -c < "$proto") bytes)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. To load on Portduino native:"
|
||||
echo " cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto"
|
||||
echo ""
|
||||
echo "To push to a hardware device:"
|
||||
echo " Use the mcp-server tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)"
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate Python protobuf bindings from the in-tree `protobufs/` submodule
|
||||
# into `bin/_generated/`. Called by bin/regen-fake-nodedbs.sh; also useful as
|
||||
# a standalone refresh after any change to a .proto file.
|
||||
#
|
||||
# Output is .gitignored — bindings are a build artifact.
|
||||
#
|
||||
# Namespace rewrite:
|
||||
# The .proto files declare `package meshtastic;`, which makes protoc emit
|
||||
# imports like `from meshtastic import mesh_pb2`. That conflicts with the
|
||||
# PyPI `meshtastic` package (which the mcp-server relies on for its
|
||||
# SerialInterface/BLEInterface transport). We post-process the generated
|
||||
# files to live under `meshtastic_v25` instead — both the directory layout
|
||||
# and all internal imports — so they coexist cleanly with the PyPI package.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v protoc >/dev/null 2>&1; then
|
||||
echo "ERROR: protoc not found in PATH." >&2
|
||||
echo " macOS: brew install protobuf" >&2
|
||||
echo " Ubuntu/Debian: apt install protobuf-compiler" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT=bin/_generated
|
||||
LOCAL_NS=meshtastic_v25
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# 1) Generate from the in-tree protos. nanopb.proto first so its descriptor
|
||||
# is available for the [(nanopb).*] options on other messages.
|
||||
protoc \
|
||||
--proto_path=protobufs \
|
||||
--python_out="$OUT" \
|
||||
protobufs/nanopb.proto \
|
||||
protobufs/meshtastic/*.proto
|
||||
|
||||
# 2) Move the generated `meshtastic/` directory to `meshtastic_v25/`.
|
||||
mv "$OUT/meshtastic" "$OUT/$LOCAL_NS"
|
||||
|
||||
# 3) Rewrite internal imports: any reference to `meshtastic.X_pb2` or
|
||||
# `from meshtastic import X_pb2` becomes `meshtastic_v25.*`.
|
||||
python3 bin/_rewrite_proto_namespace.py "$OUT/$LOCAL_NS" "$LOCAL_NS"
|
||||
|
||||
# 4) Make the package importable.
|
||||
touch "$OUT/__init__.py"
|
||||
touch "$OUT/$LOCAL_NS/__init__.py"
|
||||
|
||||
echo "regenerated Python protobuf bindings -> $OUT/$LOCAL_NS/ (namespace: $LOCAL_NS)" >&2
|
||||
Executable
+342
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile a committed seed JSONL into a binary meshtastic_NodeDatabase v25 proto.
|
||||
|
||||
The input is produced by `bin/gen-fake-nodedb-seed.py`. Timestamps in the JSONL
|
||||
are stored as `*_offset_sec` (seconds before "now"); this script resolves them
|
||||
to absolute epochs using `--now-epoch` (default: current wall clock).
|
||||
|
||||
Output is a raw `pb_encode`-compatible binary that can be dropped at
|
||||
`/prefs/nodes.proto` on the device (Portduino prefs dir or hardware via
|
||||
XModem) and loaded by `NodeDB::loadFromDisk` at boot.
|
||||
|
||||
Wire format reference:
|
||||
protobufs/meshtastic/deviceonly.proto (NodeDatabase, NodeInfoLite, sat entries)
|
||||
src/mesh/NodeDB.h:467-484 (bitfield bit positions)
|
||||
src/mesh/NodeDB.cpp:1523-1524 (pb_decode entry point)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# Prefer the in-tree generated Python protobuf bindings (bin/_generated/meshtastic_v25/)
|
||||
# because the firmware branch's protos (v25 NodeDatabase satellite arrays, slim
|
||||
# NodeInfoLite) are typically newer than what the PyPI `meshtastic` package
|
||||
# ships. Run `bin/regen-py-protos.sh` to (re)generate.
|
||||
#
|
||||
# Namespace note: the local bindings live under `meshtastic_v25` (NOT `meshtastic`)
|
||||
# to avoid shadowing the PyPI `meshtastic` package — bin/regen-py-protos.sh
|
||||
# post-processes the protoc output to rename the package.
|
||||
_HERE = pathlib.Path(__file__).resolve().parent
|
||||
_LOCAL_PROTO_DIR = _HERE / "_generated"
|
||||
if _LOCAL_PROTO_DIR.is_dir():
|
||||
sys.path.insert(0, str(_LOCAL_PROTO_DIR))
|
||||
|
||||
try:
|
||||
from meshtastic_v25.deviceonly_pb2 import ( # type: ignore[import-not-found]
|
||||
NodeDatabase,
|
||||
NodeInfoLite,
|
||||
NodePositionEntry,
|
||||
NodeTelemetryEntry,
|
||||
NodeEnvironmentEntry,
|
||||
NodeStatusEntry,
|
||||
PositionLite,
|
||||
)
|
||||
from meshtastic_v25.mesh_pb2 import HardwareModel, Position, StatusMessage # type: ignore[import-not-found]
|
||||
from meshtastic_v25.config_pb2 import Config # type: ignore[import-not-found]
|
||||
from meshtastic_v25.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics # type: ignore[import-not-found]
|
||||
except ImportError as local_err:
|
||||
# Fall back to the PyPI package if in-tree bindings haven't been generated.
|
||||
# Will fail the v25 assertion below if the PyPI package predates the
|
||||
# satellite-DB schema, but at least gives a clear "run regen-py-protos.sh"
|
||||
# error message instead of an opaque ImportError.
|
||||
try:
|
||||
from meshtastic.protobuf.deviceonly_pb2 import (
|
||||
NodeDatabase,
|
||||
NodeInfoLite,
|
||||
NodePositionEntry,
|
||||
NodeTelemetryEntry,
|
||||
NodeEnvironmentEntry,
|
||||
NodeStatusEntry,
|
||||
PositionLite,
|
||||
)
|
||||
from meshtastic.protobuf.mesh_pb2 import HardwareModel, Position, StatusMessage
|
||||
from meshtastic.protobuf.config_pb2 import Config
|
||||
from meshtastic.protobuf.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics
|
||||
except ImportError as pypi_err:
|
||||
print(
|
||||
"ERROR: could not import meshtastic protobuf bindings.\n"
|
||||
" In-tree generation: run `bin/regen-py-protos.sh` (requires protoc).\n"
|
||||
" PyPI fallback: `pip install meshtastic` (may lag firmware branch).\n"
|
||||
f" local error (meshtastic_v25): {local_err}\n"
|
||||
f" pypi error (meshtastic.protobuf): {pypi_err}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Fail loudly if bindings predate v25 (no satellite arrays).
|
||||
assert (
|
||||
hasattr(NodeDatabase, "DESCRIPTOR")
|
||||
and "positions" in NodeDatabase.DESCRIPTOR.fields_by_name
|
||||
), (
|
||||
"Loaded meshtastic bindings are older than v25 (NodeDatabase.positions missing). "
|
||||
"Run `bin/regen-py-protos.sh` against the in-tree protobufs/ submodule."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bitfield bit positions (mirror src/mesh/NodeDB.h:467-484).
|
||||
# ---------------------------------------------------------------------------
|
||||
BIT_IS_KEY_MANUALLY_VERIFIED = 0
|
||||
BIT_IS_MUTED = 1
|
||||
BIT_VIA_MQTT = 2
|
||||
BIT_IS_FAVORITE = 3
|
||||
BIT_IS_IGNORED = 4
|
||||
BIT_HAS_USER = 5
|
||||
BIT_IS_LICENSED = 6
|
||||
BIT_IS_UNMESSAGABLE = 7
|
||||
BIT_HAS_IS_UNMESSAGABLE = 8
|
||||
|
||||
BITFIELD_LAYOUT = (
|
||||
# JSON key bit position
|
||||
("is_key_manually_verified", BIT_IS_KEY_MANUALLY_VERIFIED),
|
||||
("is_muted", BIT_IS_MUTED),
|
||||
("via_mqtt", BIT_VIA_MQTT),
|
||||
("is_favorite", BIT_IS_FAVORITE),
|
||||
("is_ignored", BIT_IS_IGNORED),
|
||||
("has_user", BIT_HAS_USER),
|
||||
("is_licensed", BIT_IS_LICENSED),
|
||||
("is_unmessagable", BIT_IS_UNMESSAGABLE),
|
||||
("has_is_unmessagable", BIT_HAS_IS_UNMESSAGABLE),
|
||||
)
|
||||
|
||||
|
||||
def _pack_bitfield(bf: dict[str, bool]) -> int:
|
||||
out = 0
|
||||
for key, shift in BITFIELD_LAYOUT:
|
||||
if bf.get(key, False):
|
||||
out |= (1 << shift)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_node(node: dict[str, Any]) -> None:
|
||||
"""Friendly errors so hand-editors get clear feedback."""
|
||||
if "num" not in node or not isinstance(node["num"], str):
|
||||
raise ValueError(f"node missing/invalid 'num' (must be hex string): {node!r}")
|
||||
if "long_name" not in node:
|
||||
raise ValueError(f"node {node['num']}: missing 'long_name'")
|
||||
if len(node["long_name"]) > 24:
|
||||
raise ValueError(
|
||||
f"node {node['num']}: long_name {node['long_name']!r} is "
|
||||
f"{len(node['long_name'])} chars; max 24 (nanopb max_size:25 minus NUL)"
|
||||
)
|
||||
if "short_name" in node:
|
||||
# short_name max_size:5 (incl. NUL) → 4 bytes of content.
|
||||
# Char count is irrelevant — emojis with variation selectors (e.g. ❄️ = 6 B)
|
||||
# would slip past a `len(str) > 4` check. Always measure bytes.
|
||||
b = node["short_name"].encode("utf-8")
|
||||
if len(b) > 4:
|
||||
raise ValueError(
|
||||
f"node {node['num']}: short_name {node['short_name']!r} is "
|
||||
f"{len(b)} bytes UTF-8; max 4 (nanopb max_size:5 minus NUL)"
|
||||
)
|
||||
pk = node.get("public_key_hex", "")
|
||||
if pk and len(pk) != 64:
|
||||
raise ValueError(
|
||||
f"node {node['num']}: public_key_hex must be 64 hex chars or empty; "
|
||||
f"got {len(pk)} chars"
|
||||
)
|
||||
if pk:
|
||||
try:
|
||||
bytes.fromhex(pk)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"node {node['num']}: public_key_hex is not valid hex: {e}")
|
||||
|
||||
|
||||
def _resolve_time(
|
||||
node: dict[str, Any],
|
||||
field_absolute: str,
|
||||
field_offset: str,
|
||||
now_epoch: int,
|
||||
) -> int:
|
||||
"""If `field_absolute` is set, use it; else compute `now_epoch - offset`."""
|
||||
if field_absolute in node and node[field_absolute] is not None:
|
||||
return int(node[field_absolute])
|
||||
offset = node.get(field_offset, 0)
|
||||
return max(0, int(now_epoch) - int(offset))
|
||||
|
||||
|
||||
def _build_node_info_lite(node: dict[str, Any], now_epoch: int) -> NodeInfoLite:
|
||||
_validate_node(node)
|
||||
info = NodeInfoLite()
|
||||
info.num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
|
||||
info.long_name = node.get("long_name", "")
|
||||
info.short_name = node.get("short_name", "")
|
||||
# Enum lookups will raise ValueError on unknown names — that's exactly what we want.
|
||||
info.hw_model = HardwareModel.Value(node.get("hw_model", "UNSET"))
|
||||
info.role = Config.DeviceConfig.Role.Value(node.get("role", "CLIENT"))
|
||||
pk_hex = node.get("public_key_hex", "")
|
||||
if pk_hex:
|
||||
info.public_key = bytes.fromhex(pk_hex)
|
||||
info.snr = float(node.get("snr", 0.0))
|
||||
info.channel = int(node.get("channel", 0))
|
||||
if "hops_away" in node:
|
||||
# `optional uint32 hops_away = 9;` — in Python protobuf, assigning the
|
||||
# field implicitly sets HasField("hops_away") to True. No has_hops_away
|
||||
# setter exists (unlike the C++ nanopb-generated header).
|
||||
info.hops_away = int(node["hops_away"])
|
||||
info.next_hop = int(node.get("next_hop", 0))
|
||||
info.last_heard = _resolve_time(node, "last_heard", "last_heard_offset_sec", now_epoch)
|
||||
info.bitfield = _pack_bitfield(node.get("bitfield", {}))
|
||||
return info
|
||||
|
||||
|
||||
def _build_position_entry(num: int, pos: dict[str, Any], now_epoch: int) -> NodePositionEntry:
|
||||
entry = NodePositionEntry()
|
||||
entry.num = num
|
||||
pl = PositionLite()
|
||||
# Firmware stores lat/long as int32 in 1e-7 degrees.
|
||||
pl.latitude_i = int(round(float(pos["latitude"]) * 1e7))
|
||||
pl.longitude_i = int(round(float(pos["longitude"]) * 1e7))
|
||||
pl.altitude = int(pos.get("altitude", 0))
|
||||
pl.time = _resolve_time(pos, "time", "time_offset_sec", now_epoch)
|
||||
pl.location_source = Position.LocSource.Value(pos.get("location_source", "LOC_UNSET"))
|
||||
entry.position.CopyFrom(pl)
|
||||
return entry
|
||||
|
||||
|
||||
def _build_telemetry_entry(num: int, tel: dict[str, Any]) -> NodeTelemetryEntry:
|
||||
entry = NodeTelemetryEntry()
|
||||
entry.num = num
|
||||
dm = DeviceMetrics()
|
||||
if "battery_level" in tel:
|
||||
dm.battery_level = int(tel["battery_level"])
|
||||
if "voltage" in tel:
|
||||
dm.voltage = float(tel["voltage"])
|
||||
if "channel_utilization" in tel:
|
||||
dm.channel_utilization = float(tel["channel_utilization"])
|
||||
if "air_util_tx" in tel:
|
||||
dm.air_util_tx = float(tel["air_util_tx"])
|
||||
if "uptime_seconds" in tel:
|
||||
dm.uptime_seconds = int(tel["uptime_seconds"])
|
||||
entry.device_metrics.CopyFrom(dm)
|
||||
return entry
|
||||
|
||||
|
||||
def _build_environment_entry(num: int, env: dict[str, Any]) -> NodeEnvironmentEntry:
|
||||
entry = NodeEnvironmentEntry()
|
||||
entry.num = num
|
||||
em = EnvironmentMetrics()
|
||||
if "temperature" in env:
|
||||
em.temperature = float(env["temperature"])
|
||||
if "relative_humidity" in env:
|
||||
em.relative_humidity = float(env["relative_humidity"])
|
||||
if "barometric_pressure" in env:
|
||||
em.barometric_pressure = float(env["barometric_pressure"])
|
||||
if "iaq" in env:
|
||||
em.iaq = int(env["iaq"])
|
||||
entry.environment_metrics.CopyFrom(em)
|
||||
return entry
|
||||
|
||||
|
||||
def _build_status_entry(num: int, status: dict[str, Any]) -> NodeStatusEntry:
|
||||
# `StatusMessage` (mesh.proto:1445) has a single `string status` field.
|
||||
entry = NodeStatusEntry()
|
||||
entry.num = num
|
||||
sm = StatusMessage()
|
||||
if "status" in status:
|
||||
sm.status = str(status["status"])
|
||||
entry.status.CopyFrom(sm)
|
||||
return entry
|
||||
|
||||
|
||||
def compile_jsonl_to_proto(jsonl_path: pathlib.Path, now_epoch: int) -> bytes:
|
||||
"""Read a seed JSONL and return the encoded NodeDatabase bytes."""
|
||||
lines = jsonl_path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines:
|
||||
raise ValueError(f"{jsonl_path} is empty")
|
||||
meta_line = lines[0]
|
||||
meta_obj = json.loads(meta_line)
|
||||
meta = meta_obj.get("_meta", {})
|
||||
version = meta.get("version")
|
||||
if version != 25:
|
||||
raise ValueError(
|
||||
f"{jsonl_path}: meta version is {version!r}; this compiler "
|
||||
f"requires version=25. Regenerate the seed with the matching tooling."
|
||||
)
|
||||
|
||||
db = NodeDatabase()
|
||||
db.version = 25
|
||||
|
||||
for ln, raw in enumerate(lines[1:], start=2):
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
node = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{jsonl_path}:{ln} JSON parse error: {e}")
|
||||
|
||||
num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
|
||||
|
||||
# Header
|
||||
info = _build_node_info_lite(node, now_epoch)
|
||||
db.nodes.append(info)
|
||||
|
||||
# Satellites (nullable)
|
||||
if node.get("position"):
|
||||
db.positions.append(_build_position_entry(num, node["position"], now_epoch))
|
||||
if node.get("telemetry"):
|
||||
db.telemetry.append(_build_telemetry_entry(num, node["telemetry"]))
|
||||
if node.get("environment"):
|
||||
db.environment.append(_build_environment_entry(num, node["environment"]))
|
||||
if node.get("status"):
|
||||
db.status.append(_build_status_entry(num, node["status"]))
|
||||
|
||||
return db.SerializeToString()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Compile a seed JSONL into a binary v25 NodeDatabase proto.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--in", dest="in_path", required=True, help="Input seed JSONL.")
|
||||
p.add_argument("--out", required=True, help="Output binary .proto path.")
|
||||
p.add_argument(
|
||||
"--now-epoch",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Pin 'now' to this Unix epoch (for byte-identical CI). Default: time.time().",
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
in_path = pathlib.Path(args.in_path)
|
||||
if not in_path.is_file():
|
||||
print(f"input not found: {in_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
now_epoch = args.now_epoch if args.now_epoch is not None else int(time.time())
|
||||
|
||||
try:
|
||||
encoded = compile_jsonl_to_proto(in_path, now_epoch)
|
||||
except ValueError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
out_path = pathlib.Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(encoded)
|
||||
print(
|
||||
f"compiled {in_path} -> {out_path} ({len(encoded)} bytes, now_epoch={now_epoch})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -7,7 +7,7 @@
|
||||
"extra_flags": [
|
||||
"-D CDEBYTE_EORA_S3",
|
||||
"-D ARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-D ARDUINO_USB_MODE=0",
|
||||
"-D ARDUINO_USB_MODE=1",
|
||||
"-D ARDUINO_RUNNING_CORE=1",
|
||||
"-D ARDUINO_EVENT_RUNNING_CORE=1",
|
||||
"-D BOARD_HAS_PSRAM"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
||||
"-DBOARD_HAS_PSRAM"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DHELTEC_WIRELESS_TRACKER",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"build": {
|
||||
"cpu": "cortex-m33",
|
||||
"f_cpu": "128000000L",
|
||||
"mcu": "nrf54l15",
|
||||
"zephyr": {
|
||||
"variant": "nrf54l15dk/nrf54l15/cpuapp"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"default_tools": ["jlink"],
|
||||
"jlink_device": "nRF54L15_M33",
|
||||
"svd_path": "nrf54l15.svd"
|
||||
},
|
||||
"frameworks": ["zephyr"],
|
||||
"name": "Nordic nRF54L15-DK (PCA10156)",
|
||||
"upload": {
|
||||
"maximum_ram_size": 262144,
|
||||
"maximum_size": 1572864,
|
||||
"protocol": "jlink",
|
||||
"protocols": ["jlink"]
|
||||
},
|
||||
"url": "https://www.nordicsemi.com/Products/nRF54L15",
|
||||
"vendor": "Nordic Semiconductor"
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=0"
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DLILYGO_TBEAM_1W",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DLILYGO_TBEAM_S3_CORE",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"extra_flags": [
|
||||
"-DLILYGO_T3S3_V1",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
||||
"-DBOARD_HAS_PSRAM"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DUNPHONE_SPIN=9",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x640000,
|
||||
app1, app, ota_1, 0x650000,0x640000,
|
||||
spiffs, data, spiffs, 0xc90000,0x360000,
|
||||
coredump, data, coredump,0xFF0000,0x10000,
|
||||
|
@@ -0,0 +1,7 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x330000,
|
||||
app1, app, ota_1, 0x340000,0x330000,
|
||||
spiffs, data, spiffs, 0x670000,0x180000,
|
||||
coredump, data, coredump,0x7F0000,0x10000,
|
||||
|
@@ -70,17 +70,6 @@ def esp32_create_combined_bin(source, target, env):
|
||||
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_create_combined_bin)
|
||||
|
||||
esp32_kind = env.GetProjectOption("custom_esp32_kind")
|
||||
if esp32_kind == "esp32":
|
||||
# Free up some IRAM by removing auxiliary SPI flash chip drivers.
|
||||
# Wrapped stub symbols are defined in src/platform/esp32/iram-quirk.c.
|
||||
env.Append(
|
||||
LINKFLAGS=[
|
||||
"-Wl,--wrap=esp_flash_chip_gd",
|
||||
"-Wl,--wrap=esp_flash_chip_issi",
|
||||
"-Wl,--wrap=esp_flash_chip_winbond",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# For newer ESP32 targets, using newlib nano works better.
|
||||
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
|
||||
# Enable Newlib Nano formatting to save space
|
||||
# ...but allow printf float support (compromise)
|
||||
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
||||
|
||||
# force linker response file instead of command line arguments
|
||||
|
||||
Import("env")
|
||||
|
||||
|
||||
def wrap_with_tempfile(command_key):
|
||||
command = env.get(command_key)
|
||||
if not command or not isinstance(command, str):
|
||||
return
|
||||
if "TEMPFILE(" in command:
|
||||
return
|
||||
env.Replace(**{command_key: "${TEMPFILE('%s')}" % command})
|
||||
|
||||
|
||||
# Force SCons to spill long commands into response files on this target.
|
||||
env.Replace(MAXLINELENGTH=8192)
|
||||
|
||||
for key in ("LINKCOM", "CXXLINKCOM", "SHLINKCOM", "SHCXXLINKCOM"):
|
||||
wrap_with_tempfile(key)
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
||||
#
|
||||
# post:extra_scripts/nrf54l15_linker.py
|
||||
#
|
||||
# Fix for Zephyr two-pass link on nRF54L15:
|
||||
# platformio-build.py registers env.Depends("$PROG_PATH", final_ld_script) but
|
||||
# the SCons dependency chain is broken (final_ld_script Command never runs).
|
||||
# This script adds a PreAction on the final firmware binary that runs the gcc
|
||||
# preprocessing command directly (extracted from build.ninja) to generate
|
||||
# zephyr/linker.cmd before the link step.
|
||||
#
|
||||
# PlatformIO bundles an old Ninja that can't handle multi-output depslog rules,
|
||||
# so we parse the COMMAND line from build.ninja and run just the gcc -E part,
|
||||
# skipping the cmake_transform_depfile step (only needed for Ninja deps tracking).
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
Import("env")
|
||||
|
||||
if env.get("PIOENV") != "nrf54l15dk":
|
||||
pass # Only for the nrf54l15dk environment
|
||||
else:
|
||||
|
||||
def _extract_gcc_command(ninja_build):
|
||||
"""Parse build.ninja to find the gcc -E command that generates linker.cmd.
|
||||
|
||||
The rule format depends on the host:
|
||||
Windows (CMake's RunCMake wraps every command):
|
||||
COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..."
|
||||
POSIX (Linux/macOS — no wrapper):
|
||||
COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ...
|
||||
|
||||
Returns (gcc_cmd_string, cwd_path) or raises RuntimeError.
|
||||
"""
|
||||
in_rule = False
|
||||
with open(ninja_build, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
# Detect start of the linker.cmd custom command rule
|
||||
if not in_rule:
|
||||
if "build zephyr/linker.cmd" in line and "CUSTOM_COMMAND" in line:
|
||||
in_rule = True
|
||||
continue
|
||||
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("COMMAND = "):
|
||||
continue
|
||||
|
||||
command_val = stripped[len("COMMAND = ") :]
|
||||
|
||||
# On Windows the value is wrapped in `cmd.exe /C "..."` — strip
|
||||
# the wrapper. On POSIX hosts the inner sequence is the value
|
||||
# itself (no quoting layer).
|
||||
m = re.search(r'/C\s+"(.*)"\s*$', command_val)
|
||||
inner = m.group(1) if m else command_val
|
||||
parts = inner.split(" && ")
|
||||
|
||||
cwd = None
|
||||
gcc_cmd = None
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if part.startswith("cd /D "): # Windows form
|
||||
cwd = part[len("cd /D ") :]
|
||||
elif part.startswith("cd "): # POSIX form
|
||||
cwd = part[len("cd ") :]
|
||||
elif "arm-none-eabi-gcc" in part:
|
||||
gcc_cmd = part
|
||||
|
||||
if not gcc_cmd:
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: arm-none-eabi-gcc command not found in:\n%s"
|
||||
% inner[:400]
|
||||
)
|
||||
|
||||
return gcc_cmd, cwd
|
||||
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: 'build zephyr/linker.cmd' rule not found in build.ninja"
|
||||
)
|
||||
|
||||
def _generate_linker_cmd(target, source, env):
|
||||
"""Generate zephyr/linker.cmd via direct gcc invocation before the final link."""
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
zephyr_dir = os.path.join(build_dir, "zephyr")
|
||||
linker_cmd = os.path.join(zephyr_dir, "linker.cmd")
|
||||
|
||||
if os.path.exists(linker_cmd):
|
||||
return # Already present — nothing to do
|
||||
|
||||
ninja_build = os.path.join(build_dir, "build.ninja")
|
||||
if not os.path.exists(ninja_build):
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: build.ninja not found at %s\n"
|
||||
"Run a full build first so CMake generates the Ninja files."
|
||||
% ninja_build
|
||||
)
|
||||
|
||||
gcc_cmd, cwd = _extract_gcc_command(ninja_build)
|
||||
run_cwd = cwd if cwd else zephyr_dir
|
||||
|
||||
print(
|
||||
"==> nRF54L15: Generating zephyr/linker.cmd (LINKER_ZEPHYR_FINAL) via GCC"
|
||||
)
|
||||
# gcc_cmd comes verbatim from our own build.ninja (never user input) and
|
||||
# contains Windows-style paths with spaces that cannot be safely argv-split
|
||||
# with shlex, so we run it via the platform shell. nosec/nosemgrep below
|
||||
# acknowledge this deliberate, scoped use of shell=True.
|
||||
result = subprocess.run( # nosec B602
|
||||
gcc_cmd,
|
||||
shell=True, # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
|
||||
cwd=run_cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("GCC stdout:", result.stdout[:2000])
|
||||
print("GCC stderr:", result.stderr[:2000])
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: GCC failed to generate linker.cmd (rc=%d)"
|
||||
% result.returncode
|
||||
)
|
||||
if not os.path.exists(linker_cmd):
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: GCC returned 0 but linker.cmd was not created at %s"
|
||||
% linker_cmd
|
||||
)
|
||||
print("==> linker.cmd generated successfully")
|
||||
|
||||
# Use PIOMAINPROG (set by ZephyrBuildProgram) to get the exact SCons node
|
||||
prog = env.get("PIOMAINPROG")
|
||||
if prog:
|
||||
env.AddPreAction(prog, _generate_linker_cmd)
|
||||
else:
|
||||
print(
|
||||
"[nrf54l15_linker] WARNING: PIOMAINPROG not set, falling back to $PROG_PATH"
|
||||
)
|
||||
env.AddPreAction(env.subst("$PROG_PATH"), _generate_linker_cmd)
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload.
|
||||
|
||||
The fixture pipeline is two-stage:
|
||||
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
|
||||
fake-but-realistic peers. Committed under `test/fixtures/nodedb/`.
|
||||
2. `bin/seed-json-to-proto.py` compiles JSONL → binary v25 NodeDatabase
|
||||
protobuf with fresh wall-clock timestamps.
|
||||
|
||||
This module exposes `push_fake_nodedb(...)`, the MCP tool that:
|
||||
- target="portduino": compiles the JSONL into the device's prefs dir on
|
||||
the local filesystem (`~/.portduino/<config>/prefs/nodes.proto`).
|
||||
- target="hardware": compiles to a temp file, then streams it over the
|
||||
XModem protocol (via the meshtastic SerialInterface/BLEInterface +
|
||||
`meshtastic.xmodempacket` pubsub topic) to `/prefs/nodes.proto` on the
|
||||
device. Triggers a reboot so the firmware loads the new state on next
|
||||
boot.
|
||||
|
||||
XModem wire details (mirrors firmware impl at src/xmodem.cpp:115-260):
|
||||
* 128-byte chunks; final chunk padded to 128 B with 0x1A (SUB) bytes.
|
||||
* CRC16-CCITT (poly 0x1021, init 0x0000).
|
||||
* SOH/seq=0 carries the destination filename in `buffer.bytes`. ACK if
|
||||
`FSCom.open(filename, FILE_O_WRITE)` succeeds; NAK otherwise.
|
||||
* SOH/seq≥1 carries a 128-byte chunk. ACK = advance; NAK = retransmit.
|
||||
* EOT after the last chunk flushes + closes the file on-device.
|
||||
|
||||
Hardware push requires `confirm=True` (mirrors factory_reset / erase_and_flash
|
||||
in the .github/copilot-instructions.md "never do these without asking" list).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import pathlib
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from .connection import connect, is_tcp_port
|
||||
|
||||
# Resolve repo root so the tool works regardless of mcp-server cwd.
|
||||
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
_SEED_DIR = _REPO_ROOT / "test" / "fixtures" / "nodedb"
|
||||
_COMPILE_SCRIPT = _REPO_ROOT / "bin" / "seed-json-to-proto.py"
|
||||
|
||||
_DEFAULT_NODES_FILENAME = "/prefs/nodes.proto"
|
||||
_XMODEM_CHUNK = 128
|
||||
_XMODEM_SUB = 0x1A
|
||||
_ACK_TIMEOUT_INIT_S = 5.0
|
||||
_ACK_TIMEOUT_CHUNK_S = 2.0
|
||||
_MAX_CHUNK_RETRIES = 5
|
||||
|
||||
_VALID_SIZES = (250, 500, 1000, 2000)
|
||||
|
||||
|
||||
class FixtureError(RuntimeError):
|
||||
"""Raised for any fixture-push failure (compile, transport, ack timeout, …)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRC16-CCITT (poly 0x1021, init 0x0000). Matches the firmware's `crc16_ccitt`.
|
||||
# Hand-rolled to avoid the optional `crcmod` dep.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
|
||||
crc = init
|
||||
for b in data:
|
||||
crc ^= b << 8
|
||||
for _ in range(8):
|
||||
if crc & 0x8000:
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
|
||||
else:
|
||||
crc = (crc << 1) & 0xFFFF
|
||||
return crc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module
|
||||
# doesn't have to duplicate the proto-encoding logic.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
|
||||
if not _COMPILE_SCRIPT.is_file():
|
||||
raise FixtureError(f"compile script missing at {_COMPILE_SCRIPT}")
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(_COMPILE_SCRIPT),
|
||||
"--in",
|
||||
str(jsonl_path),
|
||||
"--out",
|
||||
str(out_path),
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise FixtureError(
|
||||
f"seed-json-to-proto.py failed (exit {exc.returncode}):\n"
|
||||
f" stdout: {exc.stdout}\n stderr: {exc.stderr}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
|
||||
if custom is not None:
|
||||
p = pathlib.Path(custom).expanduser().resolve()
|
||||
if not p.is_file():
|
||||
raise FixtureError(f"custom_seed_jsonl not found: {p}")
|
||||
return p
|
||||
p = _SEED_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||
if not p.is_file():
|
||||
raise FixtureError(
|
||||
f"missing committed seed at {p}. "
|
||||
f"Run `./bin/regen-fake-nodedbs.sh` to generate it."
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portduino push — file copy into ~/.portduino/<config>/prefs/
|
||||
# ---------------------------------------------------------------------------
|
||||
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
|
||||
home = pathlib.Path.home()
|
||||
return home / ".portduino" / config_name / "prefs"
|
||||
|
||||
|
||||
def _push_portduino(
|
||||
size: int,
|
||||
jsonl: pathlib.Path,
|
||||
portduino_config: str,
|
||||
backup_existing: bool,
|
||||
) -> dict[str, Any]:
|
||||
prefs = _portduino_prefs_dir(portduino_config)
|
||||
prefs.mkdir(parents=True, exist_ok=True)
|
||||
target = prefs / "nodes.proto"
|
||||
backed_up_to: str | None = None
|
||||
if backup_existing and target.is_file():
|
||||
ts = int(time.time())
|
||||
backup = prefs / f"nodes.proto.bak.{ts}"
|
||||
shutil.move(str(target), str(backup))
|
||||
backed_up_to = str(backup)
|
||||
_compile_proto(jsonl, target)
|
||||
raw = target.read_bytes()
|
||||
return {
|
||||
"transport": "portduino",
|
||||
"path": str(target),
|
||||
"bytes": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"jsonl_source": str(jsonl),
|
||||
"backed_up_to": backed_up_to,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware push — XModem over BLE/serial via the meshtastic Python interface.
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class _AckEvent:
|
||||
control: int
|
||||
seq: int
|
||||
|
||||
|
||||
def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEvent:
|
||||
try:
|
||||
return q.get(timeout=timeout_s)
|
||||
except queue.Empty as exc:
|
||||
raise FixtureError(
|
||||
f"XModem response timeout after {timeout_s:.1f}s — device not responding"
|
||||
) from exc
|
||||
|
||||
|
||||
def _push_hardware(
|
||||
size: int,
|
||||
jsonl: pathlib.Path,
|
||||
port: str | None,
|
||||
reboot_after: bool,
|
||||
) -> dict[str, Any]:
|
||||
# Lazy imports so the module loads even when the meshtastic deps aren't
|
||||
# available (e.g. CI in a Python env without the package installed).
|
||||
try:
|
||||
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
|
||||
from pubsub import pub
|
||||
except ImportError as exc: # pragma: no cover — dep missing
|
||||
raise FixtureError(
|
||||
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
|
||||
) from exc
|
||||
|
||||
if is_tcp_port(port):
|
||||
raise FixtureError(
|
||||
"hardware push over TCP/portduino is not supported — use "
|
||||
"target='portduino' to drop the fixture directly into the prefs dir."
|
||||
)
|
||||
|
||||
# Compile the fixture to a temp file with fresh timestamps.
|
||||
with tempfile.NamedTemporaryFile(suffix=".proto", delete=False) as tf:
|
||||
proto_path = pathlib.Path(tf.name)
|
||||
try:
|
||||
_compile_proto(jsonl, proto_path)
|
||||
payload = proto_path.read_bytes()
|
||||
finally:
|
||||
proto_path.unlink(missing_ok=True)
|
||||
|
||||
sha256 = hashlib.sha256(payload).hexdigest()
|
||||
total_bytes = len(payload)
|
||||
|
||||
# Subscribe to XModem responses BEFORE we open the interface, so we don't
|
||||
# race the first ACK that arrives during the SOH/seq=0 handshake.
|
||||
#
|
||||
# NB: the signature MUST declare every kwarg pypubsub will see for this
|
||||
# topic, or pubsub locks the topic spec to a smaller set (whichever
|
||||
# subscribe arrives first) and then *rejects* the meshtastic library's
|
||||
# publish call with `SenderUnknownMsgDataError: unknown ... interface`.
|
||||
# The meshtastic lib publishes both `packet=` and `interface=`
|
||||
# (mesh_interface.py:1389-1395), so both must appear here.
|
||||
response_q: "queue.Queue[_AckEvent]" = queue.Queue()
|
||||
|
||||
def _on_xmodem(packet: Any = None, interface: Any = None, **_kw: Any) -> None:
|
||||
if packet is None:
|
||||
return
|
||||
response_q.put(_AckEvent(control=int(packet.control), seq=int(packet.seq)))
|
||||
|
||||
pub.subscribe(_on_xmodem, "meshtastic.xmodempacket")
|
||||
|
||||
chunks_sent = 0
|
||||
retried = 0
|
||||
rebooted = False
|
||||
|
||||
XMC = xmodem_pb2.XModem.Control
|
||||
try:
|
||||
with connect(port=port) as iface:
|
||||
# 1) Send the filename (SOH, seq=0).
|
||||
init_pkt = xmodem_pb2.XModem(
|
||||
control=XMC.Value("SOH"),
|
||||
seq=0,
|
||||
buffer=_DEFAULT_NODES_FILENAME.encode("utf-8"),
|
||||
)
|
||||
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=init_pkt))
|
||||
ack = _wait_for_response(response_q, _ACK_TIMEOUT_INIT_S)
|
||||
if ack.control != XMC.Value("ACK"):
|
||||
raise FixtureError(
|
||||
f"device refused filename {_DEFAULT_NODES_FILENAME!r} "
|
||||
f"(got control={ack.control}, expected ACK). "
|
||||
f"Filesystem full or permissions issue?"
|
||||
)
|
||||
|
||||
# 2) Stream the payload in 128 B chunks.
|
||||
for offset in range(0, total_bytes, _XMODEM_CHUNK):
|
||||
chunk = payload[offset : offset + _XMODEM_CHUNK]
|
||||
if len(chunk) < _XMODEM_CHUNK:
|
||||
# Pad final chunk to 128 B with SUB. The trailing 0x1A bytes
|
||||
# become part of the file on-device, but nanopb ignores
|
||||
# bytes past the end of the top-level message.
|
||||
chunk = chunk + bytes([_XMODEM_SUB] * (_XMODEM_CHUNK - len(chunk)))
|
||||
seq = ((offset // _XMODEM_CHUNK) + 1) % 256
|
||||
# Retry loop on NAK / timeout.
|
||||
attempts = 0
|
||||
while True:
|
||||
pkt = xmodem_pb2.XModem(
|
||||
control=XMC.Value("SOH"),
|
||||
seq=seq,
|
||||
buffer=chunk,
|
||||
crc16=_crc16_ccitt(chunk),
|
||||
)
|
||||
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=pkt))
|
||||
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
|
||||
if ack.control == XMC.Value("ACK"):
|
||||
chunks_sent += 1
|
||||
break
|
||||
if ack.control == XMC.Value("NAK"):
|
||||
attempts += 1
|
||||
retried += 1
|
||||
if attempts >= _MAX_CHUNK_RETRIES:
|
||||
# Abort: send CAN so the firmware removes the half-
|
||||
# written file via FSCom.remove(filename).
|
||||
iface._sendToRadio(
|
||||
mesh_pb2.ToRadio(
|
||||
xmodemPacket=xmodem_pb2.XModem(
|
||||
control=XMC.Value("CAN")
|
||||
)
|
||||
)
|
||||
)
|
||||
raise FixtureError(
|
||||
f"chunk seq={seq} NAK'd {attempts} times; "
|
||||
f"aborted transfer (file removed on-device)."
|
||||
)
|
||||
continue # retry the same chunk
|
||||
raise FixtureError(
|
||||
f"unexpected XModem control={ack.control} on seq={seq}"
|
||||
)
|
||||
|
||||
# 3) Tell the device we're done.
|
||||
iface._sendToRadio(
|
||||
mesh_pb2.ToRadio(
|
||||
xmodemPacket=xmodem_pb2.XModem(control=XMC.Value("EOT"))
|
||||
)
|
||||
)
|
||||
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
|
||||
if ack.control != XMC.Value("ACK"):
|
||||
raise FixtureError(f"EOT not ACKed (got control={ack.control})")
|
||||
|
||||
# 4) Reboot so loadFromDisk picks up the new file.
|
||||
if reboot_after:
|
||||
iface.localNode.reboot(secs=1)
|
||||
rebooted = True
|
||||
finally:
|
||||
try:
|
||||
pub.unsubscribe(_on_xmodem, "meshtastic.xmodempacket")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"transport": "hardware",
|
||||
"port": port,
|
||||
"filename_on_device": _DEFAULT_NODES_FILENAME,
|
||||
"bytes": total_bytes,
|
||||
"chunks_sent": chunks_sent,
|
||||
"retried": retried,
|
||||
"sha256": sha256,
|
||||
"jsonl_source": str(jsonl),
|
||||
"rebooted": rebooted,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — registered as an MCP tool in server.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
def push_fake_nodedb(
|
||||
size: int,
|
||||
target: Literal["portduino", "hardware"] = "portduino",
|
||||
*,
|
||||
port: str | None = None,
|
||||
portduino_config: str = "default",
|
||||
backup_existing: bool = True,
|
||||
confirm: bool = False,
|
||||
reboot_after: bool = True,
|
||||
custom_seed_jsonl: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
|
||||
|
||||
Args:
|
||||
size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use.
|
||||
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
|
||||
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
|
||||
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
|
||||
or BLE identifier. TCP endpoints are rejected — use target="portduino"
|
||||
instead.
|
||||
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
|
||||
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
|
||||
if present, so you can roll back.
|
||||
confirm: required True for target="hardware" (writes flash + reboots).
|
||||
reboot_after: hardware only. If True, send a 1-second reboot after the
|
||||
final ACK so loadFromDisk picks up the new file at next boot.
|
||||
custom_seed_jsonl: override the committed JSONL. Use to push a hand-edited
|
||||
test scenario.
|
||||
|
||||
Returns:
|
||||
dict with transport, bytes, sha256, etc. — depends on target.
|
||||
|
||||
"""
|
||||
if size not in _VALID_SIZES:
|
||||
raise FixtureError(
|
||||
f"size must be one of {_VALID_SIZES}; got {size!r}. "
|
||||
f"Add a new committed seed if you need a different cardinality."
|
||||
)
|
||||
|
||||
jsonl = _resolve_seed_jsonl(size, custom_seed_jsonl)
|
||||
|
||||
if target == "portduino":
|
||||
return _push_portduino(size, jsonl, portduino_config, backup_existing)
|
||||
|
||||
if target == "hardware":
|
||||
if not confirm:
|
||||
raise FixtureError(
|
||||
"hardware push writes flash and triggers a reboot — pass confirm=True."
|
||||
)
|
||||
if not port:
|
||||
raise FixtureError(
|
||||
"target='hardware' requires a port (e.g. /dev/cu.usbmodemXXXX)."
|
||||
)
|
||||
return _push_hardware(size, jsonl, port, reboot_after)
|
||||
|
||||
raise FixtureError(f"unknown target {target!r}; expected 'portduino' or 'hardware'")
|
||||
@@ -15,6 +15,7 @@ from . import (
|
||||
admin,
|
||||
boards,
|
||||
devices,
|
||||
fixtures,
|
||||
flash,
|
||||
hw_tools,
|
||||
info,
|
||||
@@ -963,3 +964,45 @@ def recorder_export(
|
||||
dest_dir=dest_dir,
|
||||
streams=streams,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Fixture / test-data push --------------------------------------
|
||||
|
||||
|
||||
@app.tool()
|
||||
def push_fake_nodedb(
|
||||
size: int,
|
||||
target: str = "portduino",
|
||||
port: str | None = None,
|
||||
portduino_config: str = "default",
|
||||
backup_existing: bool = True,
|
||||
confirm: bool = False,
|
||||
reboot_after: bool = True,
|
||||
custom_seed_jsonl: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
|
||||
|
||||
Two transports:
|
||||
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
|
||||
Fast, no device connection needed.
|
||||
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
|
||||
Requires `port` + `confirm=True`. Triggers a reboot
|
||||
so loadFromDisk picks up the new file at next boot.
|
||||
|
||||
Compiles a fresh-timestamp proto from the committed JSONL seed under
|
||||
test/fixtures/nodedb/seed_v25_<N>.jsonl each invocation, so the loaded
|
||||
NodeDB always looks "recent" to the connecting phone. Structural data
|
||||
(names, IDs, positions, telemetries) is deterministic per the seed.
|
||||
|
||||
Override the JSONL via `custom_seed_jsonl` to push a hand-edited scenario.
|
||||
"""
|
||||
return fixtures.push_fake_nodedb(
|
||||
size=size,
|
||||
target=target, # type: ignore[arg-type]
|
||||
port=port,
|
||||
portduino_config=portduino_config,
|
||||
backup_existing=backup_existing,
|
||||
confirm=confirm,
|
||||
reboot_after=reboot_after,
|
||||
custom_seed_jsonl=custom_seed_jsonl,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
|
||||
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
|
||||
|
||||
Lives under tests/unit/ because none of these touch real hardware — they
|
||||
shell out to the bin/ scripts and decode the resulting protobufs in-process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
SEED_GEN = REPO_ROOT / "bin" / "gen-fake-nodedb-seed.py"
|
||||
COMPILE = REPO_ROOT / "bin" / "seed-json-to-proto.py"
|
||||
FIXTURES_DIR = REPO_ROOT / "test" / "fixtures" / "nodedb"
|
||||
|
||||
# Ensure the locally-generated Python protobuf bindings are importable.
|
||||
# These live under `meshtastic_v25` (not `meshtastic`) so they don't shadow
|
||||
# the PyPI `meshtastic` package that the rest of the mcp-server depends on.
|
||||
_BINDINGS_DIR = REPO_ROOT / "bin" / "_generated"
|
||||
if _BINDINGS_DIR.is_dir() and str(_BINDINGS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_BINDINGS_DIR))
|
||||
|
||||
try:
|
||||
from meshtastic_v25.deviceonly_pb2 import (
|
||||
NodeDatabase, # type: ignore[import-not-found]
|
||||
)
|
||||
except ImportError:
|
||||
NodeDatabase = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _require_v25_bindings() -> None:
|
||||
if NodeDatabase is None:
|
||||
pytest.skip(
|
||||
"v25 Python protobuf bindings missing; run `./bin/regen-py-protos.sh`."
|
||||
)
|
||||
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
|
||||
pytest.skip(
|
||||
"Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`."
|
||||
)
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> None:
|
||||
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed generator: deterministic for given --seed (no wall-clock dependence).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_seed_generator_is_deterministic(tmp_path: pathlib.Path) -> None:
|
||||
a = tmp_path / "a.jsonl"
|
||||
b = tmp_path / "b.jsonl"
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"100",
|
||||
"--seed",
|
||||
"42",
|
||||
"--out",
|
||||
str(a),
|
||||
]
|
||||
)
|
||||
# Sleep so any sneaky wall-clock leak in the generator would surface as
|
||||
# a byte diff between the two runs.
|
||||
time.sleep(0.8)
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"100",
|
||||
"--seed",
|
||||
"42",
|
||||
"--out",
|
||||
str(b),
|
||||
]
|
||||
)
|
||||
assert a.read_bytes() == b.read_bytes()
|
||||
|
||||
|
||||
def test_seed_generator_meta_line(tmp_path: pathlib.Path) -> None:
|
||||
out = tmp_path / "seed.jsonl"
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"50",
|
||||
"--seed",
|
||||
"1",
|
||||
"--out",
|
||||
str(out),
|
||||
]
|
||||
)
|
||||
lines = out.read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 51 # 1 meta + 50 nodes
|
||||
meta = json.loads(lines[0])
|
||||
assert "_meta" in meta
|
||||
assert meta["_meta"]["version"] == 25
|
||||
assert meta["_meta"]["count"] == 50
|
||||
assert meta["_meta"]["seed"] == 1
|
||||
|
||||
|
||||
def test_seed_only_uses_active_hardware_and_roles(tmp_path: pathlib.Path) -> None:
|
||||
"""Confirm no deprecated roles + no off-list HW models leak through."""
|
||||
out = tmp_path / "seed.jsonl"
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"500",
|
||||
"--seed",
|
||||
"7",
|
||||
"--out",
|
||||
str(out),
|
||||
]
|
||||
)
|
||||
forbidden_roles = {"ROUTER_CLIENT", "REPEATER"}
|
||||
forbidden_hw = {
|
||||
"TLORA_V1",
|
||||
"TLORA_V2",
|
||||
"TLORA_V1_1P3",
|
||||
"TLORA_V2_1_1P6",
|
||||
"TLORA_V2_1_1P8",
|
||||
"HELTEC_V1",
|
||||
"HELTEC_V2_0",
|
||||
"HELTEC_V2_1",
|
||||
"TBEAM",
|
||||
"TBEAM_V0P7",
|
||||
"NANO_G1",
|
||||
"NANO_G1_EXPLORER",
|
||||
"NANO_G2_ULTRA",
|
||||
"STATION_G1",
|
||||
"STATION_G2",
|
||||
"PORTDUINO",
|
||||
"ANDROID_SIM",
|
||||
"DIY_V1",
|
||||
"LORA_RELAY_V1",
|
||||
"NRF52840_PCA10059",
|
||||
"NRF52_UNKNOWN",
|
||||
"DR_DEV",
|
||||
"GENIEBLOCKS",
|
||||
"M5STACK",
|
||||
"RP2040_LORA",
|
||||
"PPR",
|
||||
}
|
||||
for raw in out.read_text(encoding="utf-8").splitlines()[1:]:
|
||||
node = json.loads(raw)
|
||||
assert node["role"] not in forbidden_roles, f"deprecated role: {node['role']}"
|
||||
assert (
|
||||
node["hw_model"] not in forbidden_hw
|
||||
), f"non-tier-1 HW: {node['hw_model']}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile step + committed seeds.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("size", [250, 500, 1000, 2000])
|
||||
def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path) -> None:
|
||||
_require_v25_bindings()
|
||||
proto = tmp_path / "out.proto"
|
||||
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
|
||||
|
||||
db = NodeDatabase()
|
||||
db.ParseFromString(proto.read_bytes())
|
||||
assert db.version == 25
|
||||
assert len(db.nodes) == size
|
||||
nums = {n.num for n in db.nodes}
|
||||
assert len(nums) == size, "node numbers must be unique"
|
||||
assert all(n.long_name and n.short_name for n in db.nodes)
|
||||
assert all(len(n.long_name) <= 24 for n in db.nodes) # max_size:25 - NUL
|
||||
|
||||
# Coverage sanity (±10pp tolerance for binomial fluctuation).
|
||||
def in_range(actual: int, expected_ratio: float, tol_pp: float = 0.10) -> bool:
|
||||
lo = max(0, int((expected_ratio - tol_pp) * size))
|
||||
hi = min(size, int((expected_ratio + tol_pp) * size))
|
||||
return lo <= actual <= hi
|
||||
|
||||
assert in_range(len(db.positions), 0.85)
|
||||
assert in_range(len(db.telemetry), 0.70)
|
||||
assert in_range(len(db.environment), 0.25)
|
||||
assert in_range(len(db.status), 0.40)
|
||||
|
||||
|
||||
def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
|
||||
"""Same JSONL compiled twice → identical structure, different timestamps."""
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
a = tmp_path / "a.proto"
|
||||
b = tmp_path / "b.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
|
||||
time.sleep(1.2)
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(b)])
|
||||
|
||||
da = NodeDatabase()
|
||||
db_ = NodeDatabase()
|
||||
da.ParseFromString(a.read_bytes())
|
||||
db_.ParseFromString(b.read_bytes())
|
||||
|
||||
# Zero out timestamp fields and confirm everything else is byte-identical.
|
||||
for d in (da, db_):
|
||||
for n in d.nodes:
|
||||
n.last_heard = 0
|
||||
for p in d.positions:
|
||||
p.position.time = 0
|
||||
assert da.SerializeToString() == db_.SerializeToString()
|
||||
|
||||
# Re-load fresh copies to confirm timestamps actually moved.
|
||||
aa = NodeDatabase()
|
||||
bb = NodeDatabase()
|
||||
aa.ParseFromString(a.read_bytes())
|
||||
bb.ParseFromString(b.read_bytes())
|
||||
aa_max = max(n.last_heard for n in aa.nodes if n.last_heard)
|
||||
bb_max = max(n.last_heard for n in bb.nodes if n.last_heard)
|
||||
assert bb_max >= aa_max
|
||||
assert bb_max - aa_max < 5 # within a few seconds
|
||||
|
||||
|
||||
def test_compile_pinned_now_epoch_is_byte_identical(tmp_path: pathlib.Path) -> None:
|
||||
"""With --now-epoch pinned, two compiles produce identical bytes."""
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present")
|
||||
a = tmp_path / "a.proto"
|
||||
b = tmp_path / "b.proto"
|
||||
for o in (a, b):
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(COMPILE),
|
||||
"--in",
|
||||
str(jsonl),
|
||||
"--now-epoch",
|
||||
"1700000000",
|
||||
"--out",
|
||||
str(o),
|
||||
]
|
||||
)
|
||||
assert a.read_bytes() == b.read_bytes()
|
||||
|
||||
|
||||
def test_compile_timestamps_are_recent(tmp_path: pathlib.Path) -> None:
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present")
|
||||
out = tmp_path / "out.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(out)])
|
||||
db = NodeDatabase()
|
||||
db.ParseFromString(out.read_bytes())
|
||||
now = int(time.time())
|
||||
# No timestamp older than 7 days, none in the future.
|
||||
for n in db.nodes:
|
||||
if n.last_heard:
|
||||
assert now - 7 * 86400 <= n.last_heard <= now
|
||||
# At least half should be within the last hour
|
||||
# (matches expovariate(mean=3600s)).
|
||||
recent = sum(1 for n in db.nodes if n.last_heard and n.last_heard >= now - 3600)
|
||||
assert recent >= 0.4 * len(db.nodes)
|
||||
|
||||
|
||||
def test_compile_hand_edit_round_trip(tmp_path: pathlib.Path) -> None:
|
||||
"""Edit one JSONL line, recompile, confirm edit appears in the proto."""
|
||||
_require_v25_bindings()
|
||||
src = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not src.is_file():
|
||||
pytest.skip("250-node seed not present")
|
||||
dst = tmp_path / "edited.jsonl"
|
||||
lines = src.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
# Find a node that already has telemetry so the index relationship is
|
||||
# easy to assert on the other side.
|
||||
edit_idx = None
|
||||
for i, raw in enumerate(lines[1:], start=1):
|
||||
node = json.loads(raw)
|
||||
if node.get("telemetry") is not None:
|
||||
edit_idx = i
|
||||
break
|
||||
assert edit_idx is not None, "expected at least one node with telemetry"
|
||||
|
||||
node = json.loads(lines[edit_idx])
|
||||
target_num = int(node["num"], 16)
|
||||
node["long_name"] = "Hand Edited Node"
|
||||
node["telemetry"] = {
|
||||
"battery_level": 42,
|
||||
"voltage": 3.71,
|
||||
"channel_utilization": 0.0,
|
||||
"air_util_tx": 0.0,
|
||||
"uptime_seconds": 1,
|
||||
}
|
||||
lines[edit_idx] = json.dumps(node, ensure_ascii=False, sort_keys=True)
|
||||
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
out = tmp_path / "out.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(dst), "--out", str(out)])
|
||||
db = NodeDatabase()
|
||||
db.ParseFromString(out.read_bytes())
|
||||
edited = next((n for n in db.nodes if n.num == target_num), None)
|
||||
assert edited is not None
|
||||
assert edited.long_name == "Hand Edited Node"
|
||||
tel = next((t for t in db.telemetry if t.num == target_num), None)
|
||||
assert tel is not None
|
||||
assert tel.device_metrics.battery_level == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc smoke checks on the module surface.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_crc16_ccitt_matches_known_vectors() -> None:
|
||||
"""Sanity-check the hand-rolled CRC16-CCITT matches well-known vectors.
|
||||
|
||||
Test vectors from the XModem-CRC spec (init=0, poly=0x1021):
|
||||
crc16("123456789") = 0x31C3
|
||||
crc16("") = 0x0000
|
||||
"""
|
||||
from meshtastic_mcp.fixtures import _crc16_ccitt
|
||||
|
||||
assert _crc16_ccitt(b"") == 0x0000
|
||||
assert _crc16_ccitt(b"123456789") == 0x31C3
|
||||
|
||||
|
||||
def test_push_fake_nodedb_rejects_invalid_size() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="size must be one of"):
|
||||
push_fake_nodedb(size=999, target="portduino") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_push_fake_nodedb_hardware_requires_confirm() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="confirm=True"):
|
||||
push_fake_nodedb(size=250, target="hardware", port="/dev/cu.fake")
|
||||
|
||||
|
||||
def test_push_fake_nodedb_hardware_requires_port() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="requires a port"):
|
||||
push_fake_nodedb(size=250, target="hardware", confirm=True)
|
||||
|
||||
|
||||
def test_push_fake_nodedb_hardware_rejects_tcp_port() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="not supported"):
|
||||
push_fake_nodedb(
|
||||
size=250, target="hardware", confirm=True, port="tcp://localhost:4403"
|
||||
)
|
||||
+8
-5
@@ -2,7 +2,7 @@
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[platformio]
|
||||
default_envs = tbeam
|
||||
default_envs = heltec-v3
|
||||
|
||||
extra_configs =
|
||||
variants/*/*.ini
|
||||
@@ -17,6 +17,7 @@ test_build_src = true
|
||||
extra_scripts =
|
||||
pre:bin/platformio-pre.py
|
||||
bin/platformio-custom.py
|
||||
post:extra_scripts/nrf54l15_linker.py
|
||||
; note: we add src to our include search path so that lmic_project_config can override
|
||||
; note: TINYGPS_OPTION_NO_CUSTOM_FIELDS is VERY important. We don't use custom fields and somewhere in that pile
|
||||
; of code is a heap corruption bug!
|
||||
@@ -49,6 +50,7 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DRADIOLIB_EXCLUDE_PAGER=1
|
||||
-DRADIOLIB_EXCLUDE_FSK4=1
|
||||
-DRADIOLIB_EXCLUDE_APRS=1
|
||||
-DRADIOLIB_EXCLUDE_ADSB=1
|
||||
-DRADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-DMESHTASTIC_EXCLUDE_DROPZONE=1
|
||||
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
|
||||
@@ -57,7 +59,6 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
||||
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
|
||||
-DMESHTASTIC_EXCLUDE_POWERMON=1
|
||||
-DMESHTASTIC_EXCLUDE_STATUS=1
|
||||
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
|
||||
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
|
||||
#-D OLED_PL=1
|
||||
@@ -121,7 +122,7 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
||||
|
||||
[device-ui_base]
|
||||
lib_deps =
|
||||
@@ -171,8 +172,8 @@ lib_deps =
|
||||
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
||||
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
|
||||
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
|
||||
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
|
||||
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
|
||||
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
||||
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
||||
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
||||
@@ -227,6 +228,8 @@ lib_deps =
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
|
||||
+1
-1
Submodule protobufs updated: 59cb394dcf...519a0c7c9c
@@ -152,15 +152,16 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
|
||||
// Default Bluetooth PIN
|
||||
#define defaultBLEPin 123456
|
||||
|
||||
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||
#if HAS_ETHERNET && defined(USE_ARDUINO_ETHERNET)
|
||||
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5500 auto-detect
|
||||
#elif HAS_ETHERNET && defined(USE_CH390D)
|
||||
#include <ESP32_CH390.h>
|
||||
#elif HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#include <RAK13800_W5100S.h>
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
#if HAS_ETHERNET && defined(USE_WS5500)
|
||||
#include <ETHClass2.h>
|
||||
#define ETH ETH2
|
||||
#if HAS_ETHERNET && defined(ARCH_ESP32)
|
||||
#include <ETH.h>
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
#if HAS_WIFI
|
||||
|
||||
+24
-10
@@ -1,42 +1,56 @@
|
||||
#include "DisplayFormatters.h"
|
||||
#include "MeshRadio.h"
|
||||
|
||||
const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName,
|
||||
bool usePreset)
|
||||
{
|
||||
|
||||
// If use_preset is false, always return "Custom"
|
||||
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
|
||||
// rely on this being a stable literal for channel-name hashing and default-channel detection.
|
||||
if (!usePreset) {
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
switch (preset) {
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
|
||||
case PRESET(SHORT_TURBO):
|
||||
return useShortName ? "ShortT" : "ShortTurbo";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
|
||||
case PRESET(SHORT_SLOW):
|
||||
return useShortName ? "ShortS" : "ShortSlow";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
|
||||
case PRESET(SHORT_FAST):
|
||||
return useShortName ? "ShortF" : "ShortFast";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
|
||||
case PRESET(MEDIUM_SLOW):
|
||||
return useShortName ? "MedS" : "MediumSlow";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
|
||||
case PRESET(MEDIUM_FAST):
|
||||
return useShortName ? "MedF" : "MediumFast";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
|
||||
case PRESET(LONG_SLOW):
|
||||
return useShortName ? "LongS" : "LongSlow";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
|
||||
case PRESET(LONG_FAST):
|
||||
return useShortName ? "LongF" : "LongFast";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO:
|
||||
case PRESET(LONG_TURBO):
|
||||
return useShortName ? "LongT" : "LongTurbo";
|
||||
break;
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
|
||||
case PRESET(LONG_MODERATE):
|
||||
return useShortName ? "LongM" : "LongMod";
|
||||
break;
|
||||
case PRESET(LITE_FAST):
|
||||
return useShortName ? "LiteF" : "LiteFast";
|
||||
break;
|
||||
case PRESET(LITE_SLOW):
|
||||
return useShortName ? "LiteS" : "LiteSlow";
|
||||
break;
|
||||
case PRESET(NARROW_FAST):
|
||||
return useShortName ? "NarF" : "NarrowFast";
|
||||
break;
|
||||
case PRESET(NARROW_SLOW):
|
||||
return useShortName ? "NarS" : "NarrowSlow";
|
||||
break;
|
||||
default:
|
||||
return useShortName ? "Custom" : "Invalid";
|
||||
break;
|
||||
|
||||
+65
-95
@@ -79,28 +79,46 @@ bool copyFile(const char *from, const char *to)
|
||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// take SPI Lock
|
||||
spiLock->lock();
|
||||
// rename was fixed for ESP32 IDF LittleFS in April
|
||||
bool result = FSCom.rename(pathFrom, pathTo);
|
||||
spiLock->unlock();
|
||||
return result;
|
||||
#else
|
||||
// copyFile does its own locking.
|
||||
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Platform-agnostic filesystem format / wipe.
|
||||
*
|
||||
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
|
||||
* native FSCom.format() which erases and reinitialises the LittleFS
|
||||
* partition.
|
||||
*
|
||||
* On Portduino the fs::FS backend has no format() method. We instead
|
||||
* delete /prefs (the only meshtastic data directory written at runtime)
|
||||
* and return. rmDir("/prefs") is already called unconditionally by
|
||||
* factoryReset() so this is a proven primitive on Portduino.
|
||||
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
|
||||
*
|
||||
* @return true on success, false on failure or if no filesystem is configured.
|
||||
*/
|
||||
bool fsFormat()
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
rmDir("/prefs");
|
||||
return FSBegin();
|
||||
#else
|
||||
return FSCom.format();
|
||||
#endif
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the list of files in a directory.
|
||||
*
|
||||
@@ -123,23 +141,21 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (file) {
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
|
||||
#else
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
|
||||
#endif
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
#ifdef ARCH_ESP32
|
||||
strcpy(fileInfo.file_name, file.path());
|
||||
#else
|
||||
strcpy(fileInfo.file_name, file.name());
|
||||
#endif
|
||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
@@ -163,98 +179,59 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
char buffer[255];
|
||||
#endif
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root) {
|
||||
if (!root || !root.isDirectory())
|
||||
return;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (
|
||||
file &&
|
||||
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
listDir(file.path(), levels - 1, del);
|
||||
listDir(filepath, levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Remove %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(file.name(), levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (directory)", file.name());
|
||||
listDir(file.name(), levels - 1, del);
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Delete %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.path());
|
||||
strncpy(buffer, root.path(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.name());
|
||||
strncpy(buffer, root.name(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
const char *rootpath = root.path();
|
||||
#else
|
||||
root.close();
|
||||
const char *rootpath = root.name();
|
||||
#endif
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", rootpath);
|
||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -268,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
void rmDir(const char *dirname)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(dirname, 10, true);
|
||||
#elif defined(ARCH_NRF52)
|
||||
// nRF52 implementation of LittleFS has a recursive delete function
|
||||
FSCom.rmdir_r(dirname);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -307,7 +277,7 @@ void fsInit()
|
||||
*/
|
||||
void setupSDCard()
|
||||
{
|
||||
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI)
|
||||
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) && !defined(HAS_SD_MMC)
|
||||
concurrency::LockGuard g(spiLock);
|
||||
SDHandler.begin(SPI_SCK, SPI_MISO, SPI_MOSI);
|
||||
if (!SD.begin(SDCARD_CS, SDHandler, SD_SPI_FREQUENCY)) {
|
||||
|
||||
@@ -48,10 +48,19 @@ using namespace STM32_LittleFS_Namespace;
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_NRF54L15)
|
||||
// nRF54L15 — Zephyr LittleFS on 36 KB storage_partition (internal RRAM)
|
||||
#include "InternalFileSystem.h"
|
||||
#define FSCom InternalFS
|
||||
#define FSBegin() FSCom.begin()
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
#endif
|
||||
|
||||
void fsInit();
|
||||
void fsListFiles();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||
void rmDir(const char *dirname);
|
||||
|
||||
+3
-6
@@ -53,8 +53,7 @@ class GPSStatus : public Status
|
||||
int32_t getLatitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.latitude_i;
|
||||
return localPosition.latitude_i;
|
||||
} else {
|
||||
return p.latitude_i;
|
||||
}
|
||||
@@ -63,8 +62,7 @@ class GPSStatus : public Status
|
||||
int32_t getLongitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.longitude_i;
|
||||
return localPosition.longitude_i;
|
||||
} else {
|
||||
return p.longitude_i;
|
||||
}
|
||||
@@ -73,8 +71,7 @@ class GPSStatus : public Status
|
||||
int32_t getAltitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.altitude;
|
||||
return localPosition.altitude;
|
||||
} else {
|
||||
return p.altitude;
|
||||
}
|
||||
|
||||
+26
-31
@@ -6,7 +6,6 @@
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/draw/MessageRenderer.h"
|
||||
#include <cstring> // memcpy
|
||||
|
||||
#ifndef MESSAGE_TEXT_POOL_SIZE
|
||||
@@ -181,13 +180,8 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
|
||||
|
||||
bool isDM = (sm.dest != 0 && sm.dest != NODENUM_BROADCAST);
|
||||
|
||||
if (packet.from == 0) {
|
||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
||||
sm.ackStatus = AckStatus::NONE;
|
||||
} else {
|
||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
||||
sm.ackStatus = AckStatus::ACKED;
|
||||
}
|
||||
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
|
||||
sm.ackStatus = (packet.from == 0) ? AckStatus::NONE : AckStatus::ACKED;
|
||||
|
||||
addLiveMessage(sm);
|
||||
|
||||
@@ -372,26 +366,25 @@ void MessageStore::clearAllMessages()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Internal helper: erase first or last message matching a predicate
|
||||
template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deque, Predicate pred, bool fromBack = false)
|
||||
// Internal helpers for targeted erasure.
|
||||
template <typename Predicate> static bool eraseFirstMatch(std::deque<StoredMessage> &deque, Predicate pred)
|
||||
{
|
||||
if (fromBack) {
|
||||
// Iterate from the back and erase all matches from the end
|
||||
for (auto it = deque.rbegin(); it != deque.rend();) {
|
||||
if (pred(*it)) {
|
||||
it = std::deque<StoredMessage>::reverse_iterator(deque.erase(std::next(it).base()));
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
for (auto it = deque.begin(); it != deque.end(); ++it) {
|
||||
if (pred(*it)) {
|
||||
deque.erase(it);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Manual forward search to erase all matches
|
||||
for (auto it = deque.begin(); it != deque.end();) {
|
||||
if (pred(*it)) {
|
||||
it = deque.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
template <typename Predicate> static void eraseAllMatches(std::deque<StoredMessage> &deque, Predicate pred)
|
||||
{
|
||||
for (auto it = deque.begin(); it != deque.end();) {
|
||||
if (pred(*it)) {
|
||||
it = deque.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +392,9 @@ template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deq
|
||||
// Delete oldest message (RAM + persisted queue)
|
||||
void MessageStore::deleteOldestMessage()
|
||||
{
|
||||
eraseIf(liveMessages, [](StoredMessage &) { return true; });
|
||||
if (!liveMessages.empty()) {
|
||||
liveMessages.pop_front();
|
||||
}
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
@@ -407,14 +402,14 @@ void MessageStore::deleteOldestMessage()
|
||||
void MessageStore::deleteOldestMessageInChannel(uint8_t channel)
|
||||
{
|
||||
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
||||
eraseIf(liveMessages, pred);
|
||||
eraseFirstMatch(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
void MessageStore::deleteAllMessagesInChannel(uint8_t channel)
|
||||
{
|
||||
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
|
||||
eraseIf(liveMessages, pred, false /* delete ALL, not just first */);
|
||||
eraseAllMatches(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
@@ -427,7 +422,7 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer)
|
||||
uint32_t other = (m.sender == local) ? m.dest : m.sender;
|
||||
return other == peer;
|
||||
};
|
||||
eraseIf(liveMessages, pred, false);
|
||||
eraseAllMatches(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
@@ -440,7 +435,7 @@ void MessageStore::deleteOldestMessageWithPeer(uint32_t peer)
|
||||
uint32_t other = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
|
||||
return other == peer;
|
||||
};
|
||||
eraseIf(liveMessages, pred);
|
||||
eraseFirstMatch(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +124,6 @@ class MessageStore
|
||||
// Allocate text into pool (used by sender-side code)
|
||||
static uint16_t storeText(const char *src, size_t len);
|
||||
|
||||
// Used when loading from flash to rebuild the text pool
|
||||
static uint16_t rebuildTextFromFlash(const char *src, size_t len);
|
||||
|
||||
private:
|
||||
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
|
||||
std::string filename; // Flash filename for persistence
|
||||
|
||||
+207
-140
@@ -24,6 +24,13 @@
|
||||
#include "meshUtils.h"
|
||||
#include "power/PowerHAL.h"
|
||||
#include "sleep.h"
|
||||
#ifdef ARCH_ESP32
|
||||
// #include <driver/adc.h>
|
||||
#include <esp_adc/adc_cali.h>
|
||||
#include <esp_adc/adc_cali_scheme.h>
|
||||
#include <esp_adc/adc_oneshot.h>
|
||||
#include <esp_err.h>
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#include "api/WiFiServerAPI.h"
|
||||
@@ -40,6 +47,22 @@
|
||||
#include "concurrency/LockGuard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
|
||||
#include "stm32yyxx_ll_adc.h"
|
||||
|
||||
/* Analog read resolution */
|
||||
#if defined(LL_ADC_RESOLUTION_12B)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_RESOLUTION_12B
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#elif defined(LL_ADC_DS_DATA_WIDTH_12_BIT)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_DS_DATA_WIDTH_12_BIT
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#else
|
||||
#error "ADC resolution could not be defined!"
|
||||
#endif
|
||||
#define ADC_RANGE (1 << BATTERY_SENSE_RESOLUTION_BITS)
|
||||
#endif
|
||||
|
||||
#if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
#include "target_specific.h"
|
||||
@@ -47,9 +70,8 @@
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && defined(USE_WS5500)
|
||||
#include <ETHClass2.h>
|
||||
#define ETH ETH2
|
||||
#if HAS_ETHERNET && defined(ARCH_ESP32)
|
||||
#include <ETH.h>
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
#endif
|
||||
@@ -61,33 +83,113 @@
|
||||
#if defined(BATTERY_PIN) && defined(ARCH_ESP32)
|
||||
|
||||
#ifndef BAT_MEASURE_ADC_UNIT // ADC1 is default
|
||||
static const adc1_channel_t adc_channel = ADC_CHANNEL;
|
||||
static const adc_channel_t adc_channel = ADC_CHANNEL;
|
||||
static const adc_unit_t unit = ADC_UNIT_1;
|
||||
#else // ADC2
|
||||
static const adc2_channel_t adc_channel = ADC_CHANNEL;
|
||||
#else // ADC2
|
||||
static const adc_channel_t adc_channel = ADC_CHANNEL;
|
||||
static const adc_unit_t unit = ADC_UNIT_2;
|
||||
RTC_NOINIT_ATTR uint64_t RTC_reg_b;
|
||||
|
||||
#endif // BAT_MEASURE_ADC_UNIT
|
||||
|
||||
esp_adc_cal_characteristics_t *adc_characs = (esp_adc_cal_characteristics_t *)calloc(1, sizeof(esp_adc_cal_characteristics_t));
|
||||
static adc_oneshot_unit_handle_t adc_handle = nullptr;
|
||||
static adc_cali_handle_t adc_cali_handle = nullptr;
|
||||
static bool adc_calibrated = false;
|
||||
#ifndef ADC_ATTENUATION
|
||||
static const adc_atten_t atten = ADC_ATTEN_DB_12;
|
||||
#else
|
||||
static const adc_atten_t atten = ADC_ATTENUATION;
|
||||
#endif
|
||||
#ifdef ADC_BITWIDTH
|
||||
static const adc_bitwidth_t adc_width = ADC_BITWIDTH;
|
||||
#else
|
||||
static const adc_bitwidth_t adc_width = ADC_BITWIDTH_DEFAULT;
|
||||
#endif
|
||||
|
||||
static int adcBitWidthToBits(adc_bitwidth_t width)
|
||||
{
|
||||
switch (width) {
|
||||
case ADC_BITWIDTH_9:
|
||||
return 9;
|
||||
case ADC_BITWIDTH_10:
|
||||
return 10;
|
||||
case ADC_BITWIDTH_11:
|
||||
return 11;
|
||||
case ADC_BITWIDTH_12:
|
||||
return 12;
|
||||
#ifdef ADC_BITWIDTH_13
|
||||
case ADC_BITWIDTH_13:
|
||||
return 13;
|
||||
#endif
|
||||
default:
|
||||
return 12;
|
||||
}
|
||||
}
|
||||
|
||||
static bool initAdcCalibration()
|
||||
{
|
||||
#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
|
||||
adc_cali_curve_fitting_config_t cali_config = {
|
||||
.unit_id = unit,
|
||||
.atten = atten,
|
||||
.bitwidth = adc_width,
|
||||
};
|
||||
esp_err_t ret = adc_cali_create_scheme_curve_fitting(&cali_config, &adc_cali_handle);
|
||||
if (ret == ESP_OK) {
|
||||
LOG_INFO("ADC calibration: curve fitting enabled");
|
||||
return true;
|
||||
}
|
||||
if (ret != ESP_ERR_NOT_SUPPORTED) {
|
||||
LOG_WARN("ADC calibration: curve fitting failed: %s", esp_err_to_name(ret));
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED
|
||||
adc_cali_line_fitting_config_t cali_config = {
|
||||
.unit_id = unit,
|
||||
.atten = atten,
|
||||
.bitwidth = adc_width,
|
||||
.default_vref = DEFAULT_VREF,
|
||||
};
|
||||
esp_err_t ret = adc_cali_create_scheme_line_fitting(&cali_config, &adc_cali_handle);
|
||||
if (ret == ESP_OK) {
|
||||
LOG_INFO("ADC calibration: line fitting enabled");
|
||||
return true;
|
||||
}
|
||||
if (ret != ESP_ERR_NOT_SUPPORTED) {
|
||||
LOG_WARN("ADC calibration: line fitting failed: %s", esp_err_to_name(ret));
|
||||
}
|
||||
#endif
|
||||
|
||||
LOG_INFO("ADC calibration not supported; using approximate scaling");
|
||||
return false;
|
||||
}
|
||||
|
||||
#endif // BATTERY_PIN && ARCH_ESP32
|
||||
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#ifndef EXT_PWR_DETECT_MODE
|
||||
#define EXT_PWR_DETECT_MODE INPUT
|
||||
// If using internal pull resistors, we can infer EXT_PWR_DETECT_VALUE
|
||||
#elif EXT_PWR_DETECT_MODE == INPUT_PULLUP
|
||||
#define EXT_PWR_DETECT_VALUE LOW
|
||||
#elif EXT_PWR_DETECT_MODE == INPUT_PULLDOWN
|
||||
#define EXT_PWR_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#ifndef EXT_PWR_DETECT_VALUE
|
||||
#define EXT_PWR_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
#ifndef EXT_CHRG_DETECT_MODE
|
||||
static const uint8_t ext_chrg_detect_mode = INPUT;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_mode = EXT_CHRG_DETECT_MODE;
|
||||
#define EXT_CHRG_DETECT_MODE INPUT
|
||||
// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE
|
||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP
|
||||
#define EXT_CHRG_DETECT_VALUE LOW
|
||||
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN
|
||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#ifndef EXT_CHRG_DETECT_VALUE
|
||||
static const uint8_t ext_chrg_detect_value = HIGH;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_value = EXT_CHRG_DETECT_VALUE;
|
||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -328,11 +430,29 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_ESP32 // ADC block for espressif platforms
|
||||
raw = espAdcRead();
|
||||
scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs);
|
||||
#ifdef ARCH_STM32WL
|
||||
// STM32 ADC with VREFINT runtime calibration
|
||||
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
|
||||
raw = analogRead(BATTERY_PIN);
|
||||
scaled = __LL_ADC_CALC_DATA_TO_VOLTAGE(Vref, raw, LL_ADC_RESOLUTION);
|
||||
scaled *= operativeAdcMultiplier;
|
||||
#else // block for all other platforms
|
||||
#elif defined(ARCH_ESP32) // ADC block for espressif platforms
|
||||
raw = espAdcRead();
|
||||
int voltage_mv = 0;
|
||||
if (adc_calibrated && adc_cali_handle) {
|
||||
if (adc_cali_raw_to_voltage(adc_cali_handle, raw, &voltage_mv) != ESP_OK) {
|
||||
LOG_WARN("ADC calibration read failed; using raw value");
|
||||
voltage_mv = 0;
|
||||
}
|
||||
}
|
||||
if (voltage_mv == 0) {
|
||||
// Fallback approximate conversion without calibration
|
||||
const int bits = adcBitWidthToBits(adc_width);
|
||||
const float max_code = powf(2.0f, bits) - 1.0f;
|
||||
voltage_mv = (int)((raw / max_code) * DEFAULT_VREF);
|
||||
}
|
||||
scaled = voltage_mv * operativeAdcMultiplier;
|
||||
#else // block for all other platforms
|
||||
#ifdef ARCH_NRF52
|
||||
concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock);
|
||||
#endif
|
||||
@@ -373,51 +493,22 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
uint32_t raw = 0;
|
||||
uint8_t raw_c = 0; // raw reading counter
|
||||
|
||||
#ifndef BAT_MEASURE_ADC_UNIT // ADC1
|
||||
if (!adc_handle) {
|
||||
LOG_ERROR("ADC oneshot handle not initialized");
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) {
|
||||
int val_ = adc1_get_raw(adc_channel);
|
||||
if (val_ >= 0) { // save only valid readings
|
||||
raw += val_;
|
||||
int val = 0;
|
||||
esp_err_t err = adc_oneshot_read(adc_handle, adc_channel, &val);
|
||||
if (err == ESP_OK) {
|
||||
raw += val;
|
||||
raw_c++;
|
||||
}
|
||||
// delayMicroseconds(100);
|
||||
}
|
||||
#else // ADC2
|
||||
#ifdef CONFIG_IDF_TARGET_ESP32S3 // ESP32S3
|
||||
// ADC2 wifi bug workaround not required, breaks compile
|
||||
// On ESP32S3, ADC2 can take turns with Wifi (?)
|
||||
|
||||
int32_t adc_buf;
|
||||
esp_err_t read_result;
|
||||
|
||||
// Multiple samples
|
||||
for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) {
|
||||
adc_buf = 0;
|
||||
read_result = -1;
|
||||
|
||||
read_result = adc2_get_raw(adc_channel, ADC_WIDTH_BIT_12, &adc_buf);
|
||||
if (read_result == ESP_OK) {
|
||||
raw += adc_buf;
|
||||
raw_c++; // Count valid samples
|
||||
} else {
|
||||
LOG_DEBUG("An attempt to sample ADC2 failed");
|
||||
LOG_DEBUG("ADC read failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
#else // Other ESP32
|
||||
int32_t adc_buf = 0;
|
||||
for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) {
|
||||
// ADC2 wifi bug workaround, see
|
||||
// https://github.com/espressif/arduino-esp32/issues/102
|
||||
WRITE_PERI_REG(SENS_SAR_READ_CTRL2_REG, RTC_reg_b);
|
||||
SET_PERI_REG_MASK(SENS_SAR_READ_CTRL2_REG, SENS_SAR2_DATA_INV);
|
||||
adc2_get_raw(adc_channel, ADC_WIDTH_BIT_12, &adc_buf);
|
||||
raw += adc_buf;
|
||||
raw_c++;
|
||||
}
|
||||
#endif // BAT_MEASURE_ADC_UNIT
|
||||
|
||||
#endif // End BAT_MEASURE_ADC_UNIT
|
||||
return (raw / (raw_c < 1 ? 1 : raw_c));
|
||||
}
|
||||
#endif
|
||||
@@ -447,28 +538,14 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; }
|
||||
#endif
|
||||
|
||||
/// If we see a battery voltage higher than physics allows - assume charger is
|
||||
/// pumping in power On some boards we don't have the power management chip
|
||||
/// (like AXPxxxx) so we use EXT_PWR_DETECT GPIO pin to detect external power
|
||||
/// source
|
||||
// Detect if an external power source is connected if we don’t have a PMIC;
|
||||
// Firstly prefer EXT_PWR_DETECT GPIO if available,
|
||||
// secondly try an nRF52-specific routine on some variants,
|
||||
// lastly provide a fallback to indicate external power when fully charged.
|
||||
virtual bool isVbusIn() override
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||
// if external powered that pin will be pulled down
|
||||
if (digitalRead(EXT_PWR_DETECT) == LOW) {
|
||||
return true;
|
||||
}
|
||||
// if it's not LOW - check the battery
|
||||
#else
|
||||
// if external powered that pin will be pulled up
|
||||
if (digitalRead(EXT_PWR_DETECT) == HIGH) {
|
||||
return true;
|
||||
}
|
||||
// if it's not HIGH - check the battery
|
||||
#endif
|
||||
// If we have an EXT_PWR_DETECT pin and it indicates no external power, believe it.
|
||||
return false;
|
||||
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
|
||||
|
||||
// technically speaking this should work for all(?) NRF52 boards
|
||||
// but needs testing across multiple devices. NRF52 USB would not even work if
|
||||
@@ -489,9 +566,9 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
}
|
||||
#endif
|
||||
#if defined(ELECROW_ThinkNode_M6)
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value || isVbusIn();
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn();
|
||||
#elif EXT_CHRG_DETECT
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
#elif defined(BATTERY_CHARGING_INV)
|
||||
return !digitalRead(BATTERY_CHARGING_INV);
|
||||
#else
|
||||
@@ -530,6 +607,11 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
bool initial_read_done = false;
|
||||
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
||||
uint32_t last_read_time_ms = 0;
|
||||
#ifdef ARCH_STM32WL
|
||||
// 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;
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT)
|
||||
|
||||
@@ -619,14 +701,10 @@ Power::Power() : OSThread("Power")
|
||||
bool Power::analogInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||
pinMode(EXT_PWR_DETECT, INPUT_PULLUP);
|
||||
#else
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
#endif
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
||||
#endif
|
||||
|
||||
#ifdef BATTERY_PIN
|
||||
@@ -639,47 +717,38 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_ESP32 // ESP32 needs special analog stuff
|
||||
#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 = {
|
||||
.unit_id = unit,
|
||||
};
|
||||
|
||||
#ifndef ADC_WIDTH // max resolution by default
|
||||
static const adc_bits_width_t width = ADC_WIDTH_BIT_12;
|
||||
#else
|
||||
static const adc_bits_width_t width = ADC_WIDTH;
|
||||
#endif
|
||||
#ifndef BAT_MEASURE_ADC_UNIT // ADC1
|
||||
adc1_config_width(width);
|
||||
adc1_config_channel_atten(adc_channel, atten);
|
||||
#else // ADC2
|
||||
adc2_config_channel_atten(adc_channel, atten);
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32S3
|
||||
// ADC2 wifi bug workaround
|
||||
// Not required with ESP32S3, breaks compile
|
||||
RTC_reg_b = READ_PERI_REG(SENS_SAR_READ_CTRL2_REG);
|
||||
#endif
|
||||
#endif
|
||||
// calibrate ADC
|
||||
esp_adc_cal_value_t val_type = esp_adc_cal_characterize(unit, atten, width, DEFAULT_VREF, adc_characs);
|
||||
// show ADC characterization base
|
||||
if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP) {
|
||||
LOG_INFO("ADC config based on Two Point values stored in eFuse");
|
||||
} else if (val_type == ESP_ADC_CAL_VAL_EFUSE_VREF) {
|
||||
LOG_INFO("ADC config based on reference voltage stored in eFuse");
|
||||
if (!adc_handle) {
|
||||
esp_err_t err = adc_oneshot_new_unit(&init_config, &adc_handle);
|
||||
if (err != ESP_OK) {
|
||||
LOG_ERROR("ADC oneshot init failed: %s", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#ifdef CONFIG_IDF_TARGET_ESP32S3
|
||||
// ESP32S3
|
||||
else if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP_FIT) {
|
||||
LOG_INFO("ADC config based on Two Point values and fitting curve "
|
||||
"coefficients stored in eFuse");
|
||||
|
||||
adc_oneshot_chan_cfg_t chan_cfg = {
|
||||
.atten = atten,
|
||||
.bitwidth = adc_width,
|
||||
};
|
||||
|
||||
esp_err_t err = adc_oneshot_config_channel(adc_handle, adc_channel, &chan_cfg);
|
||||
if (err != ESP_OK) {
|
||||
LOG_ERROR("ADC channel config failed: %s", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
LOG_INFO("ADC config based on default reference voltage");
|
||||
}
|
||||
#endif // ARCH_ESP32
|
||||
|
||||
adc_calibrated = initAdcCalibration();
|
||||
#endif // ARCH_ESP32
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#ifndef ARCH_ESP32
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
@@ -985,6 +1054,14 @@ int32_t Power::runOnce()
|
||||
powerFSM.trigger(EVENT_POWER_CONNECTED);
|
||||
}
|
||||
|
||||
#ifdef PMU_POWER_BUTTON_IS_CANCEL
|
||||
// cancel action also turns the screen on and off.
|
||||
if (PMU->isPekeyShortPressIrq()) {
|
||||
LOG_INFO("Input: Corona Button Click");
|
||||
InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0};
|
||||
inputBroker->injectInputEvent(&event);
|
||||
}
|
||||
#endif
|
||||
/*
|
||||
Other things we could check if we cared...
|
||||
|
||||
@@ -1001,13 +1078,6 @@ int32_t Power::runOnce()
|
||||
LOG_DEBUG("Battery removed");
|
||||
}
|
||||
*/
|
||||
#ifndef T_WATCH_S3 // FIXME - why is this triggering on the T-Watch S3?
|
||||
if (PMU->isPekeyLongPressIrq()) {
|
||||
LOG_DEBUG("PEK long button press");
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
PMU->clearIrqStatus();
|
||||
}
|
||||
@@ -1076,7 +1146,7 @@ void Power::attachPowerInterrupts()
|
||||
if (PMU) {
|
||||
attachInterrupt(
|
||||
PMU_IRQ,
|
||||
[] {
|
||||
[]() {
|
||||
pmu_irq = true;
|
||||
power->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
@@ -1379,19 +1449,16 @@ bool Power::axpChipInit()
|
||||
uint64_t pmuIrqMask = 0;
|
||||
|
||||
if (PMU->getChipModel() == XPOWERS_AXP192) {
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_BAT_INSERT_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_VBUS_REMOVE_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
} else if (PMU->getChipModel() == XPOWERS_AXP2101) {
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_BAT_INSERT_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_VBUS_REMOVE_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
}
|
||||
|
||||
pinMode(PMU_IRQ, INPUT);
|
||||
|
||||
// we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ
|
||||
// because it occurs repeatedly while there is no battery also it could cause
|
||||
// inadvertent waking from light sleep just because the battery filled we
|
||||
// don't look for AXPXXX_BATT_REMOVED_IRQ because it occurs repeatedly while
|
||||
// no battery installed we don't look at AXPXXX_VBUS_REMOVED_IRQ because we
|
||||
// don't have anything hooked to vbus
|
||||
// We wake on IRQ, so only enable the IRQs that we care about.
|
||||
// we want USB plug and unplug to update the screen and LED status,
|
||||
// and short press on the power button to trigger the "cancel" action in the UI (which also turns the screen on and off).
|
||||
PMU->enableIRQ(pmuIrqMask);
|
||||
|
||||
PMU->clearIrqStatus();
|
||||
@@ -1857,7 +1924,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
||||
{
|
||||
#if defined(EXT_CHRG_DETECT)
|
||||
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
|
||||
#endif
|
||||
return false;
|
||||
@@ -1866,7 +1933,7 @@ class SerialBatteryLevel : public HasBatteryLevel
|
||||
virtual bool isCharging() override
|
||||
{
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
|
||||
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
|
||||
|
||||
#endif
|
||||
// by default, we check the battery voltage only
|
||||
@@ -1888,10 +1955,10 @@ SerialBatteryLevel serialBatteryLevel;
|
||||
bool Power::serialBatteryInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
#endif
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
|
||||
pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
|
||||
#endif
|
||||
|
||||
bool result = serialBatteryLevel.runOnce();
|
||||
|
||||
+48
-11
@@ -58,6 +58,35 @@ static bool isPowered()
|
||||
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
|
||||
}
|
||||
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
static void t5BacklightOffForSleep()
|
||||
{
|
||||
t5BacklightSetForcedBySleep(true);
|
||||
}
|
||||
|
||||
static void t5BacklightWakeFromSleep()
|
||||
{
|
||||
t5BacklightSetForcedBySleep(false);
|
||||
}
|
||||
|
||||
static void t5BacklightOffForTimeout()
|
||||
{
|
||||
t5BacklightSetForcedByTimeout(true);
|
||||
t5TouchSetForcedByTimeout(true);
|
||||
}
|
||||
|
||||
static void t5BacklightOnFromUserInput()
|
||||
{
|
||||
t5BacklightHandleUserInput();
|
||||
t5TouchHandleUserInput();
|
||||
}
|
||||
#else
|
||||
static void t5BacklightOffForSleep() {}
|
||||
static void t5BacklightWakeFromSleep() {}
|
||||
static void t5BacklightOffForTimeout() {}
|
||||
static void t5BacklightOnFromUserInput() {}
|
||||
#endif
|
||||
|
||||
static void sdsEnter()
|
||||
{
|
||||
LOG_POWERFSM("State: SDS");
|
||||
@@ -87,6 +116,7 @@ static void lsEnter()
|
||||
LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs);
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
t5BacklightOffForSleep();
|
||||
secsSlept = 0; // How long have we been sleeping this time
|
||||
|
||||
// LOG_INFO("lsEnter end");
|
||||
@@ -159,6 +189,8 @@ static void lsIdle()
|
||||
static void lsExit()
|
||||
{
|
||||
LOG_POWERFSM("State: lsExit");
|
||||
// Lift the light-sleep force-off gate when leaving LS.
|
||||
t5BacklightWakeFromSleep();
|
||||
}
|
||||
|
||||
static void nbEnter()
|
||||
@@ -180,6 +212,8 @@ static void darkEnter()
|
||||
setBluetoothEnable(true);
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
// Screen timeout enters DARK; ensure backlight also turns off.
|
||||
t5BacklightOffForTimeout();
|
||||
}
|
||||
|
||||
static void serialEnter()
|
||||
@@ -289,12 +323,13 @@ void PowerFSM_setup()
|
||||
powerFSM.add_transition(&stateNB, &stateNB, EVENT_PACKET_FOR_PHONE, NULL, "Received packet, resetting win wake");
|
||||
|
||||
// Handle press events - note: we ignore button presses when in API mode
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL,
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput,
|
||||
"Press"); // reenter On to restart our timers
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, t5BacklightOnFromUserInput,
|
||||
"Press"); // Allow button to work while in serial API
|
||||
|
||||
// Handle critically low power battery by forcing deep sleep
|
||||
@@ -314,11 +349,13 @@ void PowerFSM_setup()
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
|
||||
|
||||
// Inputbroker
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, NULL, "Input Device");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput,
|
||||
"Input Device"); // restarts the sleep timer
|
||||
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, t5BacklightOnFromUserInput,
|
||||
"Input Device"); // restarts the sleep timer
|
||||
|
||||
powerFSM.add_transition(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
|
||||
@@ -137,7 +137,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| %02d:%02d:%02d %u ", hour, min, sec, millis() / 1000);
|
||||
::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, millis() % 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
@@ -151,7 +151,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| ??:??:?? %u ", millis() / 1000);
|
||||
::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
@@ -225,14 +225,16 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_
|
||||
isBleConnected = nimbleBluetooth && nimbleBluetooth->isActive() && nimbleBluetooth->isConnected();
|
||||
#elif defined(ARCH_NRF52)
|
||||
isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected();
|
||||
#elif defined(ARCH_NRF54L15)
|
||||
isBleConnected = nrf54l15Bluetooth != nullptr && nrf54l15Bluetooth->isConnected();
|
||||
#endif
|
||||
if (isBleConnected) {
|
||||
auto thread = concurrency::OSThread::currentThread;
|
||||
meshtastic_LogRecord logRecord = meshtastic_LogRecord_init_zero;
|
||||
logRecord.level = getLogLevel(logLevel);
|
||||
vsprintf(logRecord.message, format, arg);
|
||||
vsnprintf(logRecord.message, sizeof(logRecord.message), format, arg);
|
||||
if (thread)
|
||||
strcpy(logRecord.source, thread->ThreadName.c_str());
|
||||
strlcpy(logRecord.source, thread->ThreadName.c_str(), sizeof(logRecord.source));
|
||||
logRecord.time = getValidTime(RTCQuality::RTCQualityDevice, true);
|
||||
|
||||
auto buffer = std::unique_ptr<uint8_t[]>(new uint8_t[meshtastic_LogRecord_size]);
|
||||
@@ -241,6 +243,8 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_
|
||||
nimbleBluetooth->sendLog(buffer.get(), size);
|
||||
#elif defined(ARCH_NRF52)
|
||||
nrf52Bluetooth->sendLog(buffer.get(), size);
|
||||
#elif defined(ARCH_NRF54L15)
|
||||
nrf54l15Bluetooth->sendLog(buffer.get(), size);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ static File openFile(const char *filename, bool fullAtomic)
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
||||
#ifdef ARCH_NRF52
|
||||
FSCom.remove(filename);
|
||||
return FSCom.open(filename, FILE_O_WRITE);
|
||||
#endif
|
||||
if (!fullAtomic) {
|
||||
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
||||
}
|
||||
@@ -67,9 +63,6 @@ bool SafeFile::close()
|
||||
f.close();
|
||||
spiLock->unlock();
|
||||
|
||||
#ifdef ARCH_NRF52
|
||||
return true;
|
||||
#endif
|
||||
if (!testReadback())
|
||||
return false;
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ SerialConsole *console;
|
||||
|
||||
void consoleInit()
|
||||
{
|
||||
if (console) {
|
||||
return;
|
||||
}
|
||||
auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread
|
||||
|
||||
#if defined(SERIAL_HAS_ON_RECEIVE)
|
||||
|
||||
+4
-3
@@ -133,11 +133,12 @@ bool AirTime::isTxAllowedChannelUtil(bool polite)
|
||||
|
||||
bool AirTime::isTxAllowedAirUtil()
|
||||
{
|
||||
if (!config.lora.override_duty_cycle && myRegion->dutyCycle < 100) {
|
||||
if (utilizationTXPercent() < myRegion->dutyCycle * polite_duty_cycle_percent / 100) {
|
||||
float effectiveDutyCycle = getEffectiveDutyCycle();
|
||||
if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) {
|
||||
if (utilizationTXPercent() < effectiveDutyCycle * polite_duty_cycle_percent / 100) {
|
||||
return true;
|
||||
} else {
|
||||
LOG_WARN("TX air util. >%f%%. Skip send", myRegion->dutyCycle * polite_duty_cycle_percent / 100);
|
||||
LOG_WARN("TX air util. >%f%%. Skip send", effectiveDutyCycle * polite_duty_cycle_percent / 100);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel.
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
|
||||
RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel.
|
||||
*/
|
||||
|
||||
+14
-3
@@ -78,6 +78,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Pre-hop drop handling (compile-time flag).
|
||||
#ifndef MESHTASTIC_PREHOP_DROP
|
||||
#define MESHTASTIC_PREHOP_DROP 1
|
||||
#endif
|
||||
|
||||
/// Convert a preprocessor name into a quoted string
|
||||
#define xstr(s) ystr(s)
|
||||
#define ystr(s) #s
|
||||
@@ -229,7 +234,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define BME_ADDR 0x76
|
||||
#define BME_ADDR_ALTERNATE 0x77
|
||||
#define MCP9808_ADDR 0x18
|
||||
#define INA_ADDR 0x40
|
||||
#define INA_ADDR 0x40 // same as SHT2X
|
||||
#define INA_ADDR_ALTERNATE 0x41
|
||||
#define INA_ADDR_WAVESHARE_UPS 0x43
|
||||
#define INA3221_ADDR 0x42
|
||||
@@ -242,8 +247,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define LPS22HB_ADDR 0x5C
|
||||
#define LPS22HB_ADDR_ALT 0x5D
|
||||
#define SFA30_ADDR 0x5D
|
||||
#define SHT31_4x_ADDR 0x44
|
||||
#define SHT31_4x_ADDR_ALT 0x45
|
||||
#define SHTXX_ADDR 0x44
|
||||
#define SHTXX_ADDR_ALT 0x45
|
||||
#define PMSA003I_ADDR 0x12
|
||||
#define QMA6100P_ADDR 0x12
|
||||
#define AHT10_ADDR 0x38
|
||||
@@ -497,6 +502,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_EXCLUDE_PKI 1
|
||||
#define MESHTASTIC_EXCLUDE_POWER_FSM 1
|
||||
#define MESHTASTIC_EXCLUDE_TZ 1
|
||||
#define MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH 1
|
||||
#endif
|
||||
|
||||
// Turn off all optional modules
|
||||
@@ -513,6 +519,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
|
||||
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
|
||||
#define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1
|
||||
#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1
|
||||
#define MESHTASTIC_EXCLUDE_ATAK 1
|
||||
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
|
||||
#define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1
|
||||
@@ -554,5 +561,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define HAS_SCREEN 0
|
||||
#endif
|
||||
|
||||
#ifndef USE_ETHERNET_DEFAULT
|
||||
#define USE_ETHERNET_DEFAULT 0
|
||||
#endif
|
||||
|
||||
#include "DebugConfiguration.h"
|
||||
#include "RF95Configuration.h"
|
||||
|
||||
@@ -11,7 +11,8 @@ enum LoRaRadioType {
|
||||
SX1280_RADIO,
|
||||
LR1110_RADIO,
|
||||
LR1120_RADIO,
|
||||
LR1121_RADIO
|
||||
LR1121_RADIO,
|
||||
LR2021_RADIO
|
||||
};
|
||||
|
||||
extern LoRaRadioType radioType;
|
||||
@@ -136,7 +136,9 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
|
||||
return match;
|
||||
}
|
||||
|
||||
/// for SEN5X detection
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
// FIXME Move to a separate file for detection of sensors that require more complex interactions?
|
||||
// For SEN5X detection
|
||||
// Note, this code needs to be called before setting the I2C bus speed
|
||||
// for the screen at high speed. The speed needs to be at 100kHz, otherwise
|
||||
// detection will not work
|
||||
@@ -174,6 +176,63 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address)
|
||||
|
||||
return String(productName);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
static uint8_t crcSHT2X(const uint8_t *data, uint8_t len)
|
||||
{
|
||||
uint8_t crc = 0;
|
||||
for (uint8_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; bit++) {
|
||||
crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : crc << 1;
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
{
|
||||
uint8_t serialA[8] = {0};
|
||||
uint8_t serialB[6] = {0};
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFA);
|
||||
i2cBus->write(0x0F);
|
||||
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)sizeof(serialA)) != sizeof(serialA))
|
||||
return false;
|
||||
|
||||
for (uint8_t i = 0; i < sizeof(serialA); i++) {
|
||||
if (!i2cBus->available())
|
||||
return false;
|
||||
serialA[i] = i2cBus->read();
|
||||
}
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFC);
|
||||
i2cBus->write(0xC9);
|
||||
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)sizeof(serialB)) != sizeof(serialB))
|
||||
return false;
|
||||
|
||||
for (uint8_t i = 0; i < sizeof(serialB); i++) {
|
||||
if (!i2cBus->available())
|
||||
return false;
|
||||
serialB[i] = i2cBus->read();
|
||||
}
|
||||
|
||||
return crcSHT2X(&serialA[0], 1) == serialA[1] && crcSHT2X(&serialA[2], 1) == serialA[3] &&
|
||||
crcSHT2X(&serialA[4], 1) == serialA[5] && crcSHT2X(&serialA[6], 1) == serialA[7] &&
|
||||
crcSHT2X(&serialB[0], 2) == serialB[2] && crcSHT2X(&serialB[3], 2) == serialB[5];
|
||||
}
|
||||
#endif
|
||||
|
||||
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
||||
case ADDR: \
|
||||
@@ -371,27 +430,47 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
#endif
|
||||
#if !defined(M5STACK_UNITC6L)
|
||||
case INA_ADDR:
|
||||
case INA_ADDR: // Same as SHT2X
|
||||
case INA_ADDR_ALTERNATE:
|
||||
case INA_ADDR_WAVESHARE_UPS:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
LOG_DEBUG("Register MFG_UID: 0x%x", registerValue);
|
||||
if (registerValue == 0x5449) {
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
|
||||
LOG_DEBUG("Register DIE_UID: 0x%x", registerValue);
|
||||
case INA_ADDR_WAVESHARE_UPS: {
|
||||
uint16_t mfg = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
|
||||
if (registerValue == 0x2260) {
|
||||
LOG_DEBUG("Register MFG_UID: 0x%x", mfg);
|
||||
|
||||
// Only read DIE_UID for vendors we recognize as INA-compatible to avoid
|
||||
// an extra I2C transaction + delay on other devices sharing this address.
|
||||
if (mfg == 0x5449 || mfg == 0x190F) {
|
||||
uint16_t die = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
|
||||
LOG_DEBUG("Register DIE_UID: 0x%x", die);
|
||||
|
||||
// TI INA226 or fully compatible clones (e.g. TPA626)
|
||||
if (mfg == 0x5449 && die == 0x2260) {
|
||||
logFoundDevice("INA226", (uint8_t)addr.address);
|
||||
type = INA226;
|
||||
} else {
|
||||
}
|
||||
// Silergy SQ52201 (INA226-compatible with different IDs)
|
||||
else if (mfg == 0x190F && die == 0x0000) {
|
||||
logFoundDevice("INA226 (SQ52201)", (uint8_t)addr.address);
|
||||
type = INA226;
|
||||
}
|
||||
// TI INA260
|
||||
else if (mfg == 0x5449) {
|
||||
logFoundDevice("INA260", (uint8_t)addr.address);
|
||||
type = INA260;
|
||||
}
|
||||
} else { // Assume INA219 if INA260 ID is not found
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
|
||||
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
|
||||
type = SHTXX;
|
||||
}
|
||||
#endif
|
||||
else { // Assume INA219 if none of the above ones are found
|
||||
logFoundDevice("INA219", (uint8_t)addr.address);
|
||||
type = INA219;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case INA3221_ADDR:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue);
|
||||
@@ -448,22 +527,19 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR
|
||||
case SHTXX_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHTXX_ADDR_ALT: // same as OPT3001_ADDR
|
||||
if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
|
||||
type = OPT3001;
|
||||
logFoundDevice("OPT3001", (uint8_t)addr.address);
|
||||
} else if (i2cCommandResponseLength(addr, 0x89, 6)) { // SHT4x serial number (6 bytes inc. CRC)
|
||||
type = SHT4X;
|
||||
logFoundDevice("SHT4X", (uint8_t)addr.address);
|
||||
} else {
|
||||
type = SHT31;
|
||||
logFoundDevice("SHT31", (uint8_t)addr.address);
|
||||
} else { // SHTXX
|
||||
type = SHTXX;
|
||||
logFoundDevice("SHTXX", (uint8_t)addr.address);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address)
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address)
|
||||
case RCWL9620_ADDR:
|
||||
// get MAX30102 PARTID
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1);
|
||||
@@ -699,6 +775,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
break;
|
||||
} else {
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
String prod = "";
|
||||
prod = readSEN5xProductName(i2cBus, addr.address);
|
||||
if (prod.startsWith("SEN55")) {
|
||||
@@ -714,6 +791,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("Sensirion SEN50", addr.address);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
if (addr.address == BMX160_ADDR) {
|
||||
type = BMX160;
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
|
||||
+465
-237
@@ -103,6 +103,14 @@ static int32_t gpsSwitch()
|
||||
if (gps) {
|
||||
int currentState = digitalRead(PIN_GPS_SWITCH);
|
||||
|
||||
// Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS.
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
|
||||
gps->disable();
|
||||
lastState = currentState;
|
||||
firstrun = false;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
// if the switch is set to zero, disable the GPS Thread
|
||||
if (firstrun)
|
||||
if (currentState == LOW)
|
||||
@@ -497,12 +505,15 @@ bool GPS::setup()
|
||||
int msglen = 0;
|
||||
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
if (probeTries < GPS_PROBETRIES) {
|
||||
bootString = "Probing GPS...";
|
||||
gnssModel = probe(serialSpeeds[speedSelect]);
|
||||
if (gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
|
||||
speedSelect = 0;
|
||||
++probeTries;
|
||||
}
|
||||
} else {
|
||||
currentStep = 0;
|
||||
}
|
||||
}
|
||||
// Rare Serial Speeds
|
||||
@@ -514,6 +525,8 @@ bool GPS::setup()
|
||||
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
currentStep = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -530,86 +543,133 @@ bool GPS::setup()
|
||||
* t-beam-s3-core uses the same L76K GNSS module as t-echo.
|
||||
* Unlike t-echo, L76K uses 9600 baud rate for communication by default.
|
||||
* */
|
||||
|
||||
// Initialize the L76K Chip, use GPS + GLONASS + BEIDOU
|
||||
_serial_gps->write("$PCAS04,7*1E\r\n");
|
||||
delay(250);
|
||||
// only ask for RMC and GGA
|
||||
_serial_gps->write("$PCAS03,1,0,0,0,1,0,0,0,0,0,,,0,0*02\r\n");
|
||||
delay(250);
|
||||
// Switch to Vehicle Mode, since SoftRF enables Aviation < 2g
|
||||
_serial_gps->write("$PCAS11,3*1E\r\n");
|
||||
delay(250);
|
||||
if (currentStep == 0) {
|
||||
// Initialize the L76K Chip, use GPS + GLONASS + BEIDOU
|
||||
_serial_gps->write("$PCAS04,7*1E\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// only ask for RMC and GGA
|
||||
_serial_gps->write("$PCAS03,1,0,0,0,1,0,0,0,0,0,,,0,0*02\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
// Switch to Vehicle Mode, since SoftRF enables Aviation < 2g
|
||||
_serial_gps->write("$PCAS11,3*1E\r\n");
|
||||
currentDelay = 250;
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_MTK_L76B) {
|
||||
// Waveshare Pico-GPS hat uses the L76B with 9600 baud
|
||||
// Initialize the L76B Chip, use GPS + GLONASS
|
||||
// See note in L76_Series_GNSS_Protocol_Specification, chapter 3.29
|
||||
_serial_gps->write("$PMTK353,1,1,0,0,0*2B\r\n");
|
||||
// Above command will reset the GPS and takes longer before it will accept new commands
|
||||
delay(1000);
|
||||
// only ask for RMC and GGA (GNRMC and GNGGA)
|
||||
// See note in L76_Series_GNSS_Protocol_Specification, chapter 2.1
|
||||
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
|
||||
delay(250);
|
||||
// Enable SBAS
|
||||
_serial_gps->write("$PMTK301,2*2E\r\n");
|
||||
delay(250);
|
||||
// Enable PPS for 2D/3D fix only
|
||||
_serial_gps->write("$PMTK285,3,100*3F\r\n");
|
||||
delay(250);
|
||||
// Switch to Fitness Mode, for running and walking purpose with low speed (<5 m/s)
|
||||
_serial_gps->write("$PMTK886,1*29\r\n");
|
||||
delay(250);
|
||||
if (currentStep == 0) {
|
||||
_serial_gps->write("$PMTK353,1,1,0,0,0*2B\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 1000;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// Above command will reset the GPS and takes longer before it will accept new commands
|
||||
// only ask for RMC and GGA (GNRMC and GNGGA)
|
||||
// See note in L76_Series_GNSS_Protocol_Specification, chapter 2.1
|
||||
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
// Enable SBAS
|
||||
_serial_gps->write("$PMTK301,2*2E\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 3) {
|
||||
// Enable PPS for 2D/3D fix only
|
||||
_serial_gps->write("$PMTK285,3,100*3F\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 4) {
|
||||
// Switch to Fitness Mode, for running and walking purpose with low speed (<5 m/s)
|
||||
_serial_gps->write("$PMTK886,1*29\r\n");
|
||||
currentDelay = 250;
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_MTK_PA1010D) {
|
||||
// PA1010D is used in the Pimoroni GPS board.
|
||||
|
||||
// Enable all constellations.
|
||||
_serial_gps->write("$PMTK353,1,1,1,1,1*2A\r\n");
|
||||
// Above command will reset the GPS and takes longer before it will accept new commands
|
||||
delay(1000);
|
||||
// Only ask for RMC and GGA (GNRMC and GNGGA)
|
||||
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
|
||||
delay(250);
|
||||
// Enable SBAS / WAAS
|
||||
_serial_gps->write("$PMTK301,2*2E\r\n");
|
||||
delay(250);
|
||||
if (currentStep == 0) {
|
||||
// Enable all constellations.
|
||||
_serial_gps->write("$PMTK353,1,1,1,1,1*2A\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 1000;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// Above command will reset the GPS and takes longer before it will accept new commands
|
||||
// Only ask for RMC and GGA (GNRMC and GNGGA)
|
||||
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
// Enable SBAS / WAAS
|
||||
_serial_gps->write("$PMTK301,2*2E\r\n");
|
||||
currentDelay = 250;
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_MTK_PA1616S) {
|
||||
// PA1616S is used in some GPS breakout boards from Adafruit
|
||||
// PA1616S does not have GLONASS capability. PA1616D does, but is not implemented here.
|
||||
_serial_gps->write("$PMTK353,1,0,0,0,0*2A\r\n");
|
||||
// Above command will reset the GPS and takes longer before it will accept new commands
|
||||
delay(1000);
|
||||
// Only ask for RMC and GGA (GNRMC and GNGGA)
|
||||
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
|
||||
delay(250);
|
||||
// Enable SBAS / WAAS
|
||||
_serial_gps->write("$PMTK301,2*2E\r\n");
|
||||
delay(250);
|
||||
if (currentStep == 0) {
|
||||
_serial_gps->write("$PMTK353,1,0,0,0,0*2A\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 1000;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// Above command will reset the GPS and takes longer before it will accept new commands
|
||||
// Only ask for RMC and GGA (GNRMC and GNGGA)
|
||||
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
// Enable SBAS / WAAS
|
||||
_serial_gps->write("$PMTK301,2*2E\r\n");
|
||||
currentDelay = 250;
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_ATGM336H) {
|
||||
// Set the initial configuration of the device - these _should_ work for most AT6558 devices
|
||||
msglen = makeCASPacket(0x06, 0x07, sizeof(_message_CAS_CFG_NAVX_CONF), _message_CAS_CFG_NAVX_CONF);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACKCas(0x06, 0x07, 250) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("ATGM336H: Could not set Config");
|
||||
}
|
||||
|
||||
// Set the update frequency to 1Hz
|
||||
msglen = makeCASPacket(0x06, 0x04, sizeof(_message_CAS_CFG_RATE_1HZ), _message_CAS_CFG_RATE_1HZ);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACKCas(0x06, 0x04, 250) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("ATGM336H: Could not set Update Frequency");
|
||||
}
|
||||
|
||||
// Set the NEMA output messages
|
||||
// Ask for only RMC and GGA
|
||||
uint8_t fields[] = {CAS_NEMA_RMC, CAS_NEMA_GGA};
|
||||
for (unsigned int i = 0; i < sizeof(fields); i++) {
|
||||
if (currentStep == 0) {
|
||||
// Set the initial configuration of the device - these _should_ work for most AT6558 devices
|
||||
msglen = makeCASPacket(0x06, 0x07, sizeof(_message_CAS_CFG_NAVX_CONF), _message_CAS_CFG_NAVX_CONF);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACKCas(0x06, 0x07, 250) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("ATGM336H: Could not set Config");
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// Set the update frequency to 1Hz
|
||||
msglen = makeCASPacket(0x06, 0x04, sizeof(_message_CAS_CFG_RATE_1HZ), _message_CAS_CFG_RATE_1HZ);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACKCas(0x06, 0x04, 250) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("ATGM336H: Could not set Update Frequency");
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
// Set the NEMA output messages - Ask for only RMC and GGA
|
||||
// Construct a CAS-CFG-MSG packet
|
||||
uint8_t cas_cfg_msg_packet[] = {0x4e, fields[i], 0x01, 0x00};
|
||||
uint8_t cas_cfg_msg_packet[] = {0x4e, CAS_NEMA_RMC, 0x01, 0x00};
|
||||
msglen = makeCASPacket(0x06, 0x01, sizeof(cas_cfg_msg_packet), cas_cfg_msg_packet);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACKCas(0x06, 0x01, 250) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("ATGM336H: Could not enable NMEA MSG: %d", fields[i]);
|
||||
LOG_WARN("ATGM336H: Could not enable NMEA MSG: %d", CAS_NEMA_RMC);
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 3) {
|
||||
uint8_t cas_cfg_msg_packet[] = {0x4e, CAS_NEMA_GGA, 0x01, 0x00};
|
||||
msglen = makeCASPacket(0x06, 0x01, sizeof(cas_cfg_msg_packet), cas_cfg_msg_packet);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACKCas(0x06, 0x01, 250) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("ATGM336H: Could not enable NMEA MSG: %d", CAS_NEMA_GGA);
|
||||
}
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_UC6580) {
|
||||
@@ -617,195 +677,363 @@ bool GPS::setup()
|
||||
// use GPS L1 & L5 + BDS B1I & B2a + GLONASS L1 + GALILEO E1 & E5a + SBAS + QZSS
|
||||
// This will reset the receiver, so wait a bit afterwards
|
||||
// The paranoid will wait for the OK*04 confirmation response after each command.
|
||||
_serial_gps->write("$CFGSYS,h35155\r\n");
|
||||
delay(750);
|
||||
// Must be done after the CFGSYS command
|
||||
// Turn off GSV messages, we don't really care about which and where the sats are, maybe someday.
|
||||
_serial_gps->write("$CFGMSG,0,3,0\r\n");
|
||||
delay(250);
|
||||
// Turn off GSA messages, TinyGPS++ doesn't use this message.
|
||||
_serial_gps->write("$CFGMSG,0,2,0\r\n");
|
||||
delay(250);
|
||||
// Turn off NOTICE __TXT messages, these may provide Unicore some info but we don't care.
|
||||
_serial_gps->write("$CFGMSG,6,0,0\r\n");
|
||||
delay(250);
|
||||
_serial_gps->write("$CFGMSG,6,1,0\r\n");
|
||||
delay(250);
|
||||
} else if (IS_ONE_OF(gnssModel, GNSS_MODEL_AG3335, GNSS_MODEL_AG3352)) {
|
||||
|
||||
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_IN ||
|
||||
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_NP_865) {
|
||||
_serial_gps->write("$PAIR066,1,0,1,0,0,1*3B\r\n"); // Enable GPS+GALILEO+NAVIC
|
||||
// GPS GLONASS GALILEO BDS QZSS NAVIC
|
||||
// 1 0 1 0 0 1
|
||||
} else {
|
||||
_serial_gps->write("$PAIR066,1,1,1,1,0,0*3A\r\n"); // Enable GPS+GLONASS+GALILEO+BDS
|
||||
// GPS GLONASS GALILEO BDS QZSS NAVIC
|
||||
// 1 1 1 1 0 0
|
||||
if (currentStep == 0) {
|
||||
_serial_gps->write("$CFGSYS,h35155\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// Must be done after the CFGSYS command
|
||||
// Turn off GSV messages, we don't really care about which and where the sats are, maybe someday.
|
||||
_serial_gps->write("$CFGMSG,0,3,0\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
// Turn off GSA messages, TinyGPS++ doesn't use this message.
|
||||
_serial_gps->write("$CFGMSG,0,2,0\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 3) {
|
||||
// Turn off NOTICE __TXT messages, these may provide Unicore some info but we don't care.
|
||||
_serial_gps->write("$CFGMSG,6,0,0\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 4) {
|
||||
_serial_gps->write("$CFGMSG,6,1,0\r\n");
|
||||
currentDelay = 250;
|
||||
}
|
||||
} else if (IS_ONE_OF(gnssModel, GNSS_MODEL_AG3335, GNSS_MODEL_AG3352)) {
|
||||
if (currentStep == 0) {
|
||||
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_IN ||
|
||||
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_NP_865) {
|
||||
_serial_gps->write("$PAIR066,1,0,1,0,0,1*3B\r\n"); // Enable GPS+GALILEO+NAVIC
|
||||
// GPS GLONASS GALILEO BDS QZSS NAVIC
|
||||
// 1 0 1 0 0 1
|
||||
} else {
|
||||
_serial_gps->write("$PAIR066,1,1,1,1,0,0*3A\r\n"); // Enable GPS+GLONASS+GALILEO+BDS
|
||||
// GPS GLONASS GALILEO BDS QZSS NAVIC
|
||||
// 1 1 1 1 0 0
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// Configure NMEA (sentences will output once per fix)
|
||||
_serial_gps->write("$PAIR062,0,1*3F\r\n"); // GGA ON
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
_serial_gps->write("$PAIR062,1,0*3F\r\n"); // GLL OFF
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 3) {
|
||||
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 4) {
|
||||
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 5) {
|
||||
_serial_gps->write("$PAIR062,4,1*3B\r\n"); // RMC ON
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 6) {
|
||||
_serial_gps->write("$PAIR062,5,0*3B\r\n"); // VTG OFF
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 7) {
|
||||
_serial_gps->write("$PAIR062,6,0*38\r\n"); // ZDA ON
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 8) {
|
||||
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
|
||||
}
|
||||
// Configure NMEA (sentences will output once per fix)
|
||||
_serial_gps->write("$PAIR062,0,1*3F\r\n"); // GGA ON
|
||||
_serial_gps->write("$PAIR062,1,0*3F\r\n"); // GLL OFF
|
||||
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF
|
||||
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF
|
||||
_serial_gps->write("$PAIR062,4,1*3B\r\n"); // RMC ON
|
||||
_serial_gps->write("$PAIR062,5,0*3B\r\n"); // VTG OFF
|
||||
_serial_gps->write("$PAIR062,6,0*38\r\n"); // ZDA ON
|
||||
|
||||
delay(250);
|
||||
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
|
||||
} else if (gnssModel == GNSS_MODEL_UBLOX6) {
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
|
||||
|
||||
// Turn off unwanted NMEA messages, set update rate
|
||||
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
|
||||
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_ECO, "enable powersave ECO mode for Neo-6", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_AID, "disable UBX-AID", 500);
|
||||
|
||||
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("Unable to save GNSS module config");
|
||||
} else {
|
||||
LOG_INFO("GNSS module config saved!");
|
||||
if (currentStep == 0) {
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 3) {
|
||||
// Turn off unwanted NMEA messages, set update rate
|
||||
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 4) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 5) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 6) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 7) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 8) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 9) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 10) {
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_ECO, "enable powersave ECO mode for Neo-6", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 11) {
|
||||
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 12) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_AID, "disable UBX-AID", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 13) {
|
||||
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("Unable to save GNSS module config");
|
||||
} else {
|
||||
LOG_INFO("GNSS module config saved!");
|
||||
}
|
||||
}
|
||||
} else if (IS_ONE_OF(gnssModel, GNSS_MODEL_UBLOX7, GNSS_MODEL_UBLOX8, GNSS_MODEL_UBLOX9)) {
|
||||
if (gnssModel == GNSS_MODEL_UBLOX7) {
|
||||
LOG_DEBUG("Set GPS+SBAS");
|
||||
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_7), _message_GNSS_7);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
} else { // 8,9
|
||||
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_8), _message_GNSS_8);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
}
|
||||
|
||||
if (getACK(0x06, 0x3e, 800) == GNSS_RESPONSE_NAK) {
|
||||
// It's not critical if the module doesn't acknowledge this configuration.
|
||||
LOG_DEBUG("reconfigure GNSS - defaults maintained. Is this module GPS-only?");
|
||||
} else {
|
||||
if (currentStep == 0) {
|
||||
if (gnssModel == GNSS_MODEL_UBLOX7) {
|
||||
LOG_INFO("GPS+SBAS configured");
|
||||
LOG_DEBUG("Set GPS+SBAS");
|
||||
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_7), _message_GNSS_7);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
} else { // 8,9
|
||||
LOG_INFO("GPS+SBAS+GLONASS+Galileo configured");
|
||||
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_8), _message_GNSS_8);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
}
|
||||
// Documentation say, we need wait at least 0.5s after reconfiguration of GNSS module, before sending next
|
||||
// commands for the M8 it tends to be more... 1 sec should be enough ;>)
|
||||
delay(1000);
|
||||
}
|
||||
|
||||
// Disable Text Info messages //6,7,8,9
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
|
||||
|
||||
if (gnssModel == GNSS_MODEL_UBLOX8) { // 8
|
||||
if (getACK(0x06, 0x3e, 800) == GNSS_RESPONSE_NAK) {
|
||||
// It's not critical if the module doesn't acknowledge this configuration.
|
||||
LOG_DEBUG("reconfigure GNSS - defaults maintained. Is this module GPS-only?");
|
||||
} else {
|
||||
if (gnssModel == GNSS_MODEL_UBLOX7) {
|
||||
LOG_INFO("GPS+SBAS configured");
|
||||
} else { // 8,9
|
||||
LOG_INFO("GPS+SBAS+GLONASS+Galileo configured");
|
||||
}
|
||||
// Documentation say, we need wait at least 0.5s after reconfiguration of GNSS module, before sending next
|
||||
// commands for the M8 it tends to be more... 1 sec should be enough ;>)
|
||||
delay(1000);
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// Disable Text Info messages //6,7,8,9
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_8, "enable interference resistance", 500);
|
||||
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5_8, "configure NAVX5_8 settings", 500);
|
||||
} else { // 6,7,9
|
||||
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
|
||||
}
|
||||
// Turn off unwanted NMEA messages, set update rate
|
||||
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
|
||||
|
||||
if (ublox_info.protocol_version >= 18) {
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x86, _message_PMS, "enable powersave for GPS", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
if (gnssModel == GNSS_MODEL_UBLOX8) { // 8
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_8, "enable interference resistance", 500);
|
||||
} else { // 7,9
|
||||
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 3) {
|
||||
if (gnssModel == GNSS_MODEL_UBLOX8) { // 8
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5_8, "configure NAVX5_8 settings", 500);
|
||||
} else { // 7,9
|
||||
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 4) {
|
||||
// Turn off unwanted NMEA messages, set update rate
|
||||
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 5) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 6) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 7) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 8) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 9) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 10) {
|
||||
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 11) {
|
||||
if (ublox_info.protocol_version >= 18) {
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x86, _message_PMS, "enable powersave for GPS", 500);
|
||||
} else {
|
||||
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_PSM, "enable powersave mode for GPS", 500);
|
||||
}
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 12) {
|
||||
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
|
||||
|
||||
currentStep++;
|
||||
return false;
|
||||
} else if (currentStep == 13) {
|
||||
// For M8 we want to enable NMEA version 4.10 so we can see the additional sats.
|
||||
if (gnssModel == GNSS_MODEL_UBLOX8) {
|
||||
if (ublox_info.protocol_version >= 18 && gnssModel == GNSS_MODEL_UBLOX8) {
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x17, _message_NMEA, "enable NMEA 4.10", 500);
|
||||
currentStep++;
|
||||
return false;
|
||||
}
|
||||
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("Unable to save GNSS module config");
|
||||
} else {
|
||||
LOG_INFO("GNSS module configuration saved!");
|
||||
}
|
||||
} else if (currentStep == 14) {
|
||||
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("Unable to save GNSS module config");
|
||||
} else {
|
||||
LOG_INFO("GNSS module configuration saved!");
|
||||
}
|
||||
} else {
|
||||
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_PSM, "enable powersave mode for GPS", 500);
|
||||
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
|
||||
}
|
||||
|
||||
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("Unable to save GNSS module config");
|
||||
} else {
|
||||
LOG_INFO("GNSS module configuration saved!");
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_UBLOX10) {
|
||||
delay(1000);
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_RAM, "disable NMEA messages in M10 RAM", 300);
|
||||
delay(750);
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_BBR, "disable NMEA messages in M10 BBR", 300);
|
||||
delay(750);
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_RAM, "disable Info messages for M10 GPS RAM", 300);
|
||||
delay(750);
|
||||
// Next disable Info txt messages in BBR layer
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_BBR, "disable Info messages for M10 GPS BBR", 300);
|
||||
delay(750);
|
||||
// Do M10 configuration for Power Management.
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_RAM, "enable powersave for M10 GPS RAM", 300);
|
||||
delay(750);
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_BBR, "enable powersave for M10 GPS BBR", 300);
|
||||
delay(750);
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_RAM, "enable jam detection M10 GPS RAM", 300);
|
||||
delay(750);
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_BBR, "enable jam detection M10 GPS BBR", 300);
|
||||
delay(750);
|
||||
// Here is where the init commands should go to do further M10 initialization.
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_RAM, "disable SBAS M10 GPS RAM", 300);
|
||||
delay(750); // will cause a receiver restart so wait a bit
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_BBR, "disable SBAS M10 GPS BBR", 300);
|
||||
delay(750); // will cause a receiver restart so wait a bit
|
||||
|
||||
// Done with initialization, Now enable wanted NMEA messages in BBR layer so they will survive a periodic
|
||||
// sleep.
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_BBR, "enable messages for M10 GPS BBR", 300);
|
||||
delay(750);
|
||||
// Next enable wanted NMEA messages in RAM layer
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_RAM, "enable messages for M10 GPS RAM", 500);
|
||||
delay(750);
|
||||
|
||||
// As the M10 has no flash, the best we can do to preserve the config is to set it in RAM and BBR.
|
||||
// BBR will survive a restart, and power off for a while, but modules with small backup
|
||||
// batteries or super caps will not retain the config for a long power off time.
|
||||
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE_10), _message_SAVE_10);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("Unable to save GNSS module config");
|
||||
} else {
|
||||
LOG_INFO("GNSS module configuration saved!");
|
||||
if (currentStep == 0) {
|
||||
LOG_INFO("Configuring M10 GPS step 0");
|
||||
delay(1000);
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_RAM, "disable NMEA messages in M10 RAM", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
LOG_INFO("Configuring M10 GPS step 1");
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_BBR, "disable NMEA messages in M10 BBR", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 2) {
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_RAM, "disable Info messages for M10 GPS RAM", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 3) {
|
||||
// Next disable Info txt messages in BBR layer
|
||||
clearBuffer();
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_BBR, "disable Info messages for M10 GPS BBR", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 4) {
|
||||
// Do M10 configuration for Power Management.
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_RAM, "enable powersave for M10 GPS RAM", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 5) {
|
||||
// Next enable powersave in BBR layer
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_BBR, "enable powersave for M10 GPS BBR", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 6) {
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_RAM, "enable jam detection M10 GPS RAM", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 7) {
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_BBR, "enable jam detection M10 GPS BBR", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 8) {
|
||||
// Here is where the init commands should go to do further M10 initialization.
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_RAM, "disable SBAS M10 GPS RAM", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 9) {
|
||||
// will cause a receiver restart so wait a bit
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_BBR, "disable SBAS M10 GPS BBR", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 10) {
|
||||
// Done with initialization, Now enable wanted NMEA messages in BBR layer so they will survive a periodic
|
||||
// sleep.
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_BBR, "enable messages for M10 GPS BBR", 300);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 11) {
|
||||
// Next enable wanted NMEA messages in RAM layer
|
||||
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_RAM, "enable messages for M10 GPS RAM", 500);
|
||||
currentStep++;
|
||||
currentDelay = 750;
|
||||
return false;
|
||||
} else if (currentStep == 12) {
|
||||
// As the M10 has no flash, the best we can do to preserve the config is to set it in RAM and BBR.
|
||||
// BBR will survive a restart, and power off for a while, but modules with small backup
|
||||
// batteries or super caps will not retain the config for a long power off time.
|
||||
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE_10), _message_SAVE_10);
|
||||
_serial_gps->write(UBXscratch, msglen);
|
||||
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
|
||||
LOG_WARN("Unable to save GNSS module config");
|
||||
} else {
|
||||
LOG_INFO("GNSS module configuration saved!");
|
||||
}
|
||||
didSerialInit = true;
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_CM121) {
|
||||
// only ask for RMC and GGA
|
||||
// enable GGA
|
||||
_serial_gps->write("$CFGMSG,0,0,1,1*1B\r\n");
|
||||
delay(250);
|
||||
// enable RMC
|
||||
_serial_gps->write("$CFGMSG,0,4,1,1*1F\r\n");
|
||||
delay(250);
|
||||
if (currentStep == 0) {
|
||||
// enable GGA
|
||||
_serial_gps->write("$CFGMSG,0,0,1,1*1B\r\n");
|
||||
currentStep++;
|
||||
currentDelay = 250;
|
||||
return false;
|
||||
} else if (currentStep == 1) {
|
||||
// enable RMC
|
||||
_serial_gps->write("$CFGMSG,0,4,1,1*1F\r\n");
|
||||
currentDelay = 250;
|
||||
}
|
||||
}
|
||||
didSerialInit = true;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ class GPS : private concurrency::OSThread
|
||||
// Let the GPS hardware save power between updates
|
||||
void down();
|
||||
|
||||
bool initFinished() const { return GPSInitFinished; };
|
||||
|
||||
private:
|
||||
GPS() : concurrency::OSThread("GPS") {}
|
||||
|
||||
|
||||
+187
-221
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "draw/NodeListRenderer.h"
|
||||
#include "draw/NotificationRenderer.h"
|
||||
#include "draw/UIRenderer.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_GPS
|
||||
@@ -54,26 +55,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "graphics/images.h"
|
||||
#include "input/TouchScreenImpl1.h"
|
||||
#include "main.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/Default.h"
|
||||
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#include "modules/TextMessageModule.h"
|
||||
#include "modules/WaypointModule.h"
|
||||
#include "sleep.h"
|
||||
#include "target_specific.h"
|
||||
extern MessageStore messageStore;
|
||||
|
||||
#if USE_TFTDISPLAY
|
||||
extern uint16_t TFT_MESH;
|
||||
#else
|
||||
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
|
||||
#endif
|
||||
|
||||
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
||||
#include "mesh/wifi/WiFiAPClient.h"
|
||||
#endif
|
||||
@@ -98,6 +94,7 @@ namespace graphics
|
||||
|
||||
// This means the *visible* area (sh1106 can address 132, but shows 128 for example)
|
||||
#define IDLE_FRAMERATE 1 // in fps
|
||||
#define COMPASS_ACTIVE_FRAMERATE 20
|
||||
|
||||
// DEBUG
|
||||
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
|
||||
@@ -107,6 +104,27 @@ namespace graphics
|
||||
// A text message frame + debug frame + all the node infos
|
||||
FrameCallback *normalFrames;
|
||||
static uint32_t targetFramerate = IDLE_FRAMERATE;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
static inline void prepareFrameColorRegions()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
clearTFTColorRegions();
|
||||
// Full-frame FrameMono inversion for themes that need it (e.g. light themes).
|
||||
if (isThemeFullFrameInvert()) {
|
||||
setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(),
|
||||
screen->getHeight());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline void updateUiFrame(OLEDDisplayUi *ui)
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
ui->update();
|
||||
}
|
||||
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
|
||||
|
||||
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
|
||||
@@ -135,6 +153,60 @@ static bool heartbeat = false;
|
||||
|
||||
extern bool hasUnreadMessage;
|
||||
|
||||
static inline float wrapHeading360(float heading)
|
||||
{
|
||||
if (heading < 0.0f) {
|
||||
heading += 360.0f;
|
||||
} else if (heading >= 360.0f) {
|
||||
heading -= 360.0f;
|
||||
}
|
||||
return heading;
|
||||
}
|
||||
|
||||
void Screen::setHeading(float heading)
|
||||
{
|
||||
const float wrappedHeading = wrapHeading360(heading);
|
||||
|
||||
if (!hasCompass) {
|
||||
hasCompass = true;
|
||||
compassHeading = wrappedHeading;
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
|
||||
float delta = wrappedHeading - compassHeading;
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
|
||||
// Adaptive filtering:
|
||||
// - Strong damping for tiny deltas (jitter)
|
||||
// - Faster response for larger turns
|
||||
const float absDelta = (delta >= 0.0f) ? delta : -delta;
|
||||
if (absDelta < 1.0f) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// Overlay Alert Banner Renderer
|
||||
// ==============================
|
||||
@@ -158,6 +230,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
|
||||
#endif
|
||||
// Store the message and set the expiration timestamp
|
||||
strncpy(NotificationRenderer::alertBannerMessage, banner_overlay_options.message, 255);
|
||||
NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage);
|
||||
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
|
||||
NotificationRenderer::alertBannerUntil =
|
||||
(banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs;
|
||||
@@ -167,11 +240,11 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
|
||||
NotificationRenderer::alertBannerCallback = banner_overlay_options.bannerCallback;
|
||||
NotificationRenderer::curSelected = banner_overlay_options.InitialSelected;
|
||||
NotificationRenderer::pauseBanner = false;
|
||||
NotificationRenderer::current_notification_type = notificationTypeEnum::selection_picker;
|
||||
NotificationRenderer::current_notification_type = banner_overlay_options.notificationType;
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 2);
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
// Called to trigger a banner with custom message and duration
|
||||
@@ -191,9 +264,9 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct
|
||||
NotificationRenderer::current_notification_type = notificationTypeEnum::node_picker;
|
||||
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 2);
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
// Called to trigger a banner with custom message and duration
|
||||
@@ -215,9 +288,9 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
|
||||
NotificationRenderer::currentNumber = 0;
|
||||
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 2);
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
|
||||
@@ -238,9 +311,9 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t
|
||||
|
||||
// Set the overlay using the same pattern as other notification types
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 2);
|
||||
ui->setTargetFPS(60);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
|
||||
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
@@ -272,10 +345,25 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
|
||||
float Screen::estimatedHeading(double lat, double lon)
|
||||
{
|
||||
static double oldLat, oldLon;
|
||||
static float b;
|
||||
static float b = -1.0f;
|
||||
static uint32_t lastHeadingAtMs = 0;
|
||||
const uint32_t now = millis();
|
||||
const uint32_t gpsUpdateIntervalSecs =
|
||||
Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval);
|
||||
uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs;
|
||||
if (config.position.position_broadcast_smart_enabled) {
|
||||
const uint32_t smartMinIntervalSecs = Default::getConfiguredOrDefault(
|
||||
config.position.broadcast_smart_minimum_interval_secs, default_broadcast_smart_minimum_interval_secs);
|
||||
if (smartMinIntervalSecs > effectiveUpdateIntervalSecs) {
|
||||
effectiveUpdateIntervalSecs = smartMinIntervalSecs;
|
||||
}
|
||||
}
|
||||
// Two expected update windows; keep arithmetic 32-bit to avoid pulling in larger 64-bit helpers.
|
||||
const uint32_t headingStaleMs =
|
||||
(effectiveUpdateIntervalSecs > (UINT32_MAX / 2000U)) ? UINT32_MAX : (effectiveUpdateIntervalSecs * 2000U);
|
||||
|
||||
if (oldLat == 0) {
|
||||
// just prepare for next time
|
||||
// Need at least two position points before we can infer heading.
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
|
||||
@@ -283,12 +371,20 @@ float Screen::estimatedHeading(double lat, double lon)
|
||||
}
|
||||
|
||||
float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon);
|
||||
if (d < 10) // haven't moved enough, just keep current bearing
|
||||
if (d < 10) { // haven't moved enough, keep previous heading (invalid until first real movement)
|
||||
if (lastHeadingAtMs != 0 && (now - lastHeadingAtMs) >= headingStaleMs) {
|
||||
// Heading is stale after prolonged no-movement; force reacquire.
|
||||
b = -1.0f;
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG;
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
lastHeadingAtMs = now;
|
||||
|
||||
return b;
|
||||
}
|
||||
@@ -309,30 +405,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
{
|
||||
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
|
||||
|
||||
int32_t rawRGB = uiconfig.screen_rgb_color;
|
||||
|
||||
// Only validate the combined value once
|
||||
if (rawRGB > 0 && rawRGB <= 255255255) {
|
||||
LOG_INFO("Setting screen RGB color to user chosen: 0x%06X", rawRGB);
|
||||
// Extract each component as a normal int first
|
||||
int r = (rawRGB >> 16) & 0xFF;
|
||||
int g = (rawRGB >> 8) & 0xFF;
|
||||
int b = rawRGB & 0xFF;
|
||||
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||
TFT_MESH = COLOR565(static_cast<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(b));
|
||||
}
|
||||
#ifdef TFT_MESH_OVERRIDE
|
||||
} else if (rawRGB == 0) {
|
||||
LOG_INFO("Setting screen RGB color to TFT_MESH_OVERRIDE: 0x%04X", TFT_MESH_OVERRIDE);
|
||||
// Default to TFT_MESH_OVERRIDE if available
|
||||
TFT_MESH = TFT_MESH_OVERRIDE;
|
||||
#endif
|
||||
} else {
|
||||
// Default best readable yellow color
|
||||
LOG_INFO("Setting screen RGB color to default: (255,255,128)");
|
||||
TFT_MESH = COLOR565(255, 255, 128);
|
||||
}
|
||||
|
||||
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
|
||||
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
|
||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||
@@ -400,9 +472,13 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
#endif
|
||||
|
||||
#if defined(USE_ST7789)
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
// Keep firmware and ST7789 driver region structs layout-compatible:
|
||||
// we pass `graphics::colorRegions` through a type cast below.
|
||||
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
|
||||
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
|
||||
#elif defined(USE_ST7796)
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
||||
#endif
|
||||
|
||||
ui = new OLEDDisplayUi(dispdev);
|
||||
@@ -453,6 +529,11 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
delay(100);
|
||||
#endif
|
||||
#if !ARCH_PORTDUINO
|
||||
#if defined(USE_ST7789) && defined(VTFT_CTRL)
|
||||
// Ensure panel power rail is enabled before sending wake commands.
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, LOW);
|
||||
#endif
|
||||
dispdev->displayOn();
|
||||
#endif
|
||||
|
||||
@@ -474,10 +555,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
ui->init();
|
||||
#endif
|
||||
#if defined(USE_ST7789) && defined(VTFT_LEDA)
|
||||
#ifdef VTFT_CTRL
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, LOW);
|
||||
#endif
|
||||
ui->init();
|
||||
#ifdef ESP_PLATFORM
|
||||
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
|
||||
@@ -518,23 +595,22 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
#endif
|
||||
#ifdef USE_ST7789
|
||||
SPI1.end();
|
||||
#if defined(ARCH_ESP32)
|
||||
// Keep TFT control pins in deterministic states while timed-off.
|
||||
// Floating/default pin states can corrupt panel edge rows on wake.
|
||||
#ifdef VTFT_LEDA
|
||||
pinMode(VTFT_LEDA, ANALOG);
|
||||
pinMode(VTFT_LEDA, OUTPUT);
|
||||
digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON);
|
||||
#endif
|
||||
#ifdef VTFT_CTRL
|
||||
pinMode(VTFT_CTRL, ANALOG);
|
||||
#endif
|
||||
pinMode(ST7789_RESET, ANALOG);
|
||||
pinMode(ST7789_RS, ANALOG);
|
||||
pinMode(ST7789_NSS, ANALOG);
|
||||
#else
|
||||
nrf_gpio_cfg_default(VTFT_LEDA);
|
||||
nrf_gpio_cfg_default(VTFT_CTRL);
|
||||
nrf_gpio_cfg_default(ST7789_RESET);
|
||||
nrf_gpio_cfg_default(ST7789_RS);
|
||||
nrf_gpio_cfg_default(ST7789_NSS);
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, HIGH);
|
||||
#endif
|
||||
pinMode(ST7789_RESET, OUTPUT);
|
||||
digitalWrite(ST7789_RESET, HIGH);
|
||||
pinMode(ST7789_RS, OUTPUT);
|
||||
digitalWrite(ST7789_RS, HIGH);
|
||||
pinMode(ST7789_NSS, OUTPUT);
|
||||
digitalWrite(ST7789_NSS, HIGH);
|
||||
#endif
|
||||
#ifdef USE_ST7796
|
||||
SPI1.end();
|
||||
@@ -589,16 +665,16 @@ void Screen::setup()
|
||||
static_cast<SH1106Wire *>(dispdev)->setSubtype(7);
|
||||
#endif
|
||||
|
||||
#if defined(USE_ST7789) && defined(TFT_MESH)
|
||||
// Apply custom RGB color (e.g. Heltec T114/T190)
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#if defined(USE_ST7789)
|
||||
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
|
||||
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
|
||||
#endif
|
||||
#if defined(MUZI_BASE)
|
||||
dispdev->delayPoweron = true;
|
||||
#endif
|
||||
#if defined(USE_ST7796) && defined(TFT_MESH)
|
||||
// Custom text color, if defined in variant.h
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#if defined(USE_ST7796)
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
||||
#endif
|
||||
|
||||
// Initialize display and UI system
|
||||
@@ -625,7 +701,7 @@ void Screen::setup()
|
||||
static OverlayCallback overlays[] = {
|
||||
graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame
|
||||
};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 1);
|
||||
|
||||
// Enable UTF-8 to display mapping
|
||||
dispdev->setFontTableLookupFunction(customFontTableLookup);
|
||||
@@ -644,7 +720,7 @@ void Screen::setup()
|
||||
#endif
|
||||
{
|
||||
const char *region = myRegion ? myRegion->name : nullptr;
|
||||
graphics::UIRenderer::drawIconScreen(region, display, state, x, y);
|
||||
graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y);
|
||||
}
|
||||
};
|
||||
ui->setFrames(alertFrames, 1);
|
||||
@@ -683,9 +759,9 @@ void Screen::setup()
|
||||
// Turn on display and trigger first draw
|
||||
handleSetOn(true);
|
||||
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
#ifndef USE_EINK
|
||||
ui->update(); // Some SSD1306 clones drop the first draw, so run twice
|
||||
updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice
|
||||
#endif
|
||||
serialSinceMsec = millis();
|
||||
|
||||
@@ -758,7 +834,7 @@ void Screen::forceDisplay(bool forceUiUpdate)
|
||||
do {
|
||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||
delay(10);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||
|
||||
// Return to normal frame rate
|
||||
@@ -815,7 +891,7 @@ int32_t Screen::runOnce()
|
||||
// Show boot screen for first logo_timeout seconds, then switch to normal operation.
|
||||
// serialSinceMsec adjusts for additional serial wait time during nRF52 bootup
|
||||
static bool showingBootScreen = true;
|
||||
if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) {
|
||||
if (showingBootScreen && (!gps || gps->initFinished()) && (millis() > (logo_timeout + serialSinceMsec))) {
|
||||
LOG_INFO("Done with boot screen");
|
||||
stopBootScreen();
|
||||
showingBootScreen = false;
|
||||
@@ -829,9 +905,9 @@ int32_t Screen::runOnce()
|
||||
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
|
||||
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
|
||||
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
#ifndef USE_EINK
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
#endif
|
||||
showingOEMBootScreen = false;
|
||||
}
|
||||
@@ -922,15 +998,28 @@ int32_t Screen::runOnce()
|
||||
|
||||
// this must be before the frameState == FIXED check, because we always
|
||||
// want to draw at least one FIXED frame before doing forceDisplay
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
|
||||
// Switch to a low framerate (to save CPU) when we are not in transition
|
||||
// but we should only call setTargetFPS when framestate changes, because
|
||||
// otherwise that breaks animations.
|
||||
|
||||
if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) {
|
||||
uint32_t desiredFramerate = IDLE_FRAMERATE;
|
||||
#if HAS_GPS && !defined(USE_EINK)
|
||||
if (showingNormalScreen && hasCompass) {
|
||||
const uint8_t currentFrame = ui->getUiState()->currentFrame;
|
||||
if ((framesetInfo.positions.gps != 255 && currentFrame == framesetInfo.positions.gps) ||
|
||||
(framesetInfo.positions.waypoint != 255 && currentFrame == framesetInfo.positions.waypoint) ||
|
||||
(framesetInfo.positions.firstFavorite != 255 && currentFrame >= framesetInfo.positions.firstFavorite &&
|
||||
currentFrame <= framesetInfo.positions.lastFavorite)) {
|
||||
desiredFramerate = COMPASS_ACTIVE_FRAMERATE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (targetFramerate != desiredFramerate && ui->getUiState()->frameState == FIXED) {
|
||||
// oldFrameState = ui->getUiState()->frameState;
|
||||
targetFramerate = IDLE_FRAMERATE;
|
||||
targetFramerate = desiredFramerate;
|
||||
|
||||
ui->setTargetFPS(targetFramerate);
|
||||
forceDisplay();
|
||||
@@ -971,7 +1060,7 @@ void Screen::setSSLFrames()
|
||||
// LOG_DEBUG("Show SSL frames");
|
||||
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
|
||||
ui->setFrames(sslFrames, 1);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,7 +1096,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
|
||||
do {
|
||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||
delay(1);
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||
|
||||
#if defined(USE_EINK_PARALLELDISPLAY)
|
||||
@@ -1201,8 +1290,8 @@ void Screen::setFrames(FrameFocus focus)
|
||||
|
||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||||
if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo);
|
||||
if (n && n->num != nodeDB->getNodeNum() && nodeInfoLiteIsFavorite(n)) {
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,9 +1318,9 @@ void Screen::setFrames(FrameFocus focus)
|
||||
|
||||
// Add overlays: frame icons and alert banner)
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 2);
|
||||
|
||||
prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed)
|
||||
prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed)
|
||||
|
||||
// Focus on a specific frame, in the frame set we just created
|
||||
switch (focus) {
|
||||
@@ -1382,9 +1471,15 @@ void Screen::blink()
|
||||
dispdev->setBrightness(254);
|
||||
while (count > 0) {
|
||||
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
delay(50);
|
||||
dispdev->clear();
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
delay(50);
|
||||
count = count - 1;
|
||||
@@ -1518,6 +1613,9 @@ void Screen::setFastFramerate()
|
||||
{
|
||||
#if defined(OLED_TINY)
|
||||
dispdev->clear();
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
#endif
|
||||
// We are about to start a transition so speed up fps
|
||||
@@ -1550,138 +1648,6 @@ int Screen::handleStatusUpdate(const meshtastic::Status *arg)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handles when message is received; will jump to text message frame.
|
||||
int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
|
||||
{
|
||||
if (showingNormalScreen) {
|
||||
if (packet->from == 0) {
|
||||
// Outgoing message (likely sent from phone)
|
||||
devicestate.has_rx_text_message = false;
|
||||
memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message));
|
||||
hiddenFrames.textMessage = true;
|
||||
hasUnreadMessage = false; // Clear unread state when user replies
|
||||
|
||||
setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list
|
||||
} else {
|
||||
// Incoming message
|
||||
devicestate.has_rx_text_message = true; // Needed to include the message frame
|
||||
hasUnreadMessage = true; // Enables mail icon in the header
|
||||
setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view (no-op during text_input)
|
||||
|
||||
// Only wake/force display if the configuration allows it
|
||||
if (shouldWakeOnReceivedMessage()) {
|
||||
setOn(true); // Wake up the screen first
|
||||
forceDisplay(); // Forces screen redraw
|
||||
}
|
||||
// === Prepare banner/popup content ===
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from);
|
||||
const meshtastic_Channel channel =
|
||||
channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex());
|
||||
const char *longName = (node && node->has_user) ? node->user.long_name : nullptr;
|
||||
|
||||
const char *msgRaw = reinterpret_cast<const char *>(packet->decoded.payload.bytes);
|
||||
|
||||
char banner[256];
|
||||
|
||||
bool isAlert = false;
|
||||
|
||||
if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra ||
|
||||
moduleConfig.external_notification.alert_bell_buzzer)
|
||||
// Check for bell character to determine if this message is an alert
|
||||
for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) {
|
||||
if (msgRaw[i] == ASCII_BELL) {
|
||||
isAlert = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike generic messages, alerts (when enabled via the ext notif module) ignore any
|
||||
// 'mute' preferences set to any specific node or channel.
|
||||
// If on-screen keyboard is active, show a transient popup over keyboard instead of interrupting it
|
||||
if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) {
|
||||
// Wake and force redraw so popup is visible immediately
|
||||
if (shouldWakeOnReceivedMessage()) {
|
||||
setOn(true);
|
||||
forceDisplay();
|
||||
}
|
||||
|
||||
// Build popup: title = message source name, content = message text (sanitized)
|
||||
// Title
|
||||
char titleBuf[64] = {0};
|
||||
if (longName && longName[0]) {
|
||||
// Sanitize sender name
|
||||
std::string t = sanitizeString(longName);
|
||||
strncpy(titleBuf, t.c_str(), sizeof(titleBuf) - 1);
|
||||
} else {
|
||||
strncpy(titleBuf, "Message", sizeof(titleBuf) - 1);
|
||||
}
|
||||
|
||||
// Content: payload bytes may not be null-terminated, remove ASCII_BELL and sanitize
|
||||
char content[256] = {0};
|
||||
{
|
||||
std::string raw;
|
||||
raw.reserve(packet->decoded.payload.size);
|
||||
for (size_t i = 0; i < packet->decoded.payload.size; ++i) {
|
||||
char c = msgRaw[i];
|
||||
if (c == ASCII_BELL)
|
||||
continue; // strip bell
|
||||
raw.push_back(c);
|
||||
}
|
||||
std::string sanitized = sanitizeString(raw);
|
||||
strncpy(content, sanitized.c_str(), sizeof(content) - 1);
|
||||
}
|
||||
|
||||
NotificationRenderer::showKeyboardMessagePopupWithTitle(titleBuf, content, 3000);
|
||||
|
||||
// Maintain existing buzzer behavior on M5 if applicable
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY ||
|
||||
(isAlert && moduleConfig.external_notification.alert_bell_buzzer) ||
|
||||
(!isBroadcast(packet->to) && isToUs(packet))) {
|
||||
playLongBeep();
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
// No keyboard active: use regular banner flow, respecting mute settings
|
||||
if (isAlert) {
|
||||
if (longName && longName[0]) {
|
||||
snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName);
|
||||
} else {
|
||||
strcpy(banner, "Alert Received");
|
||||
}
|
||||
screen->showSimpleBanner(banner, 3000);
|
||||
} else if (!channel.settings.has_module_settings || !channel.settings.module_settings.is_muted) {
|
||||
if (longName && longName[0]) {
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
strcpy(banner, "New Message");
|
||||
} else {
|
||||
snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
|
||||
}
|
||||
} else {
|
||||
strcpy(banner, "New Message");
|
||||
}
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
screen->setOn(true);
|
||||
screen->showSimpleBanner(banner, 1500);
|
||||
if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY ||
|
||||
(isAlert && moduleConfig.external_notification.alert_bell_buzzer) ||
|
||||
(!isBroadcast(packet->to) && isToUs(packet))) {
|
||||
// Beep if not in DIRECT_MSG_ONLY mode or if in DIRECT_MSG_ONLY mode and either
|
||||
// - packet contains an alert and alert bell buzzer is enabled
|
||||
// - packet is a non-broadcast that is addressed to this node
|
||||
playLongBeep();
|
||||
}
|
||||
#else
|
||||
screen->showSimpleBanner(banner, 3000);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Triggered by MeshModules
|
||||
int Screen::handleUIFrameEvent(const UIFrameEvent *event)
|
||||
{
|
||||
@@ -1727,9 +1693,9 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) {
|
||||
NotificationRenderer::inEvent = *event;
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 2);
|
||||
setFastFramerate(); // Draw ASAP
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1742,9 +1708,9 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
if (NotificationRenderer::isOverlayBannerShowing()) {
|
||||
NotificationRenderer::inEvent = *event;
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
ui->setOverlays(overlays, 2);
|
||||
setFastFramerate(); // Draw ASAP
|
||||
ui->update();
|
||||
updateUiFrame(ui);
|
||||
|
||||
menuHandler::handleMenuSwitch(dispdev);
|
||||
return 0;
|
||||
|
||||
@@ -330,15 +330,11 @@ class Screen : public concurrency::OSThread
|
||||
|
||||
// Function to allow the AccelerometerThread to set the heading if a sensor provides it
|
||||
// Mutex needed?
|
||||
void setHeading(long _heading)
|
||||
{
|
||||
hasCompass = true;
|
||||
compassHeading = fmod(_heading, 360);
|
||||
}
|
||||
void setHeading(float heading);
|
||||
|
||||
bool hasHeading() { return hasCompass; }
|
||||
|
||||
long getHeading() { return compassHeading; }
|
||||
float getHeading() { return compassHeading; }
|
||||
|
||||
void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; }
|
||||
uint32_t getEndCalibration() { return endCalibrationAt; }
|
||||
@@ -613,7 +609,6 @@ class Screen : public concurrency::OSThread
|
||||
|
||||
// Handle observer events
|
||||
int handleStatusUpdate(const meshtastic::Status *arg);
|
||||
int handleTextMessage(const meshtastic_MeshPacket *packet);
|
||||
int handleUIFrameEvent(const UIFrameEvent *arg);
|
||||
int handleInputEvent(const InputEvent *arg);
|
||||
int handleAdminMessage(AdminModule_ObserverData *arg);
|
||||
@@ -792,4 +787,4 @@ extern std::vector<std::string> functionSymbol;
|
||||
extern std::string functionSymbolString;
|
||||
extern graphics::Screen *screen;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -88,6 +88,16 @@
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// nRF52 flash optimization: re-route FONT_LARGE_LOCAL to the 16pt glyph so
|
||||
// the display-tier dispatch below picks up 16pt everywhere it would have used
|
||||
// 24pt. Drops the ~9.6 KB ArialMT_Plain_24 table from the linked binary.
|
||||
// Set MESHTASTIC_LARGE_FONT_24PT=1 in build_flags to opt out per variant
|
||||
// (undefined or 0 keeps the optimization on).
|
||||
#if defined(ARCH_NRF52) && (!defined(MESHTASTIC_LARGE_FONT_24PT) || MESHTASTIC_LARGE_FONT_24PT == 0)
|
||||
#undef FONT_LARGE_LOCAL
|
||||
#define FONT_LARGE_LOCAL FONT_MEDIUM_LOCAL
|
||||
#endif
|
||||
|
||||
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
|
||||
defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || \
|
||||
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)) && \
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "RTC.h"
|
||||
#include "draw/NodeListRenderer.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/draw/UIRenderer.h"
|
||||
#include "main.h"
|
||||
#include "meshtastic/config.pb.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#include "power.h"
|
||||
#include <OLEDDisplay.h>
|
||||
#include <cctype>
|
||||
#include <graphics/images.h>
|
||||
|
||||
namespace graphics
|
||||
@@ -71,6 +75,12 @@ uint32_t lastBlinkShared = 0;
|
||||
bool isMailIconVisible = true;
|
||||
uint32_t lastMailBlink = 0;
|
||||
|
||||
static inline bool useClockHeaderAccentTheme(uint32_t themeId)
|
||||
{
|
||||
return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen ||
|
||||
themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
|
||||
}
|
||||
|
||||
// *********************************
|
||||
// * Rounded Header when inverted *
|
||||
// *********************************
|
||||
@@ -91,7 +101,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
|
||||
// *************************
|
||||
// * Common Header Drawing *
|
||||
// *************************
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date)
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date,
|
||||
bool transparent_background, bool use_title_color_override, uint16_t title_color_override)
|
||||
{
|
||||
constexpr int HEADER_OFFSET_Y = 1;
|
||||
y += HEADER_OFFSET_Y;
|
||||
@@ -106,30 +117,93 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
|
||||
const int screenW = display->getWidth();
|
||||
const int screenH = display->getHeight();
|
||||
const int headerHeight = highlightHeight + 2;
|
||||
const uint16_t headerColorForRoles = getThemeHeaderBg();
|
||||
// Color TFT headers use a fixed dark background + white glyphs.
|
||||
// Keep legacy inverted bitmap behavior only for monochrome displays.
|
||||
const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
int statusLeftEndX = 0;
|
||||
int statusRightStartX = screenW;
|
||||
const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0');
|
||||
const auto activeThemeId = getActiveTheme().id;
|
||||
const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId);
|
||||
#endif
|
||||
|
||||
{
|
||||
const uint16_t headerColor = getThemeHeaderBg();
|
||||
const uint16_t headerTextColor = getThemeHeaderText();
|
||||
const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor;
|
||||
uint16_t headerStatusColor = getThemeHeaderStatus();
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// Clock frame uses transparent header + date + empty title.
|
||||
// For accent clock themes (Pink/Creamsicle + classic monochrome), tint
|
||||
// status items (battery outline, %, date, mail icon) to the header accent.
|
||||
if (useClockHeaderAccent) {
|
||||
headerStatusColor = getThemeHeaderBg();
|
||||
}
|
||||
|
||||
if (transparent_background) {
|
||||
// Transparent clock headers should inherit whatever body off-color is
|
||||
// already active under the header (important for light/inverted themes).
|
||||
const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg());
|
||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW,
|
||||
headerHeight);
|
||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor);
|
||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor);
|
||||
} else if (useInvertedHeaderStyle) {
|
||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW,
|
||||
headerHeight);
|
||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole);
|
||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor);
|
||||
} else {
|
||||
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW,
|
||||
headerHeight);
|
||||
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor);
|
||||
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!force_no_invert) {
|
||||
// === Inverted Header Background ===
|
||||
if (isInverted) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, 0, screenW, highlightHeight + 2);
|
||||
display->fillRect(0, 0, screenW, headerHeight);
|
||||
display->setColor(WHITE);
|
||||
drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
|
||||
display->setColor(BLACK);
|
||||
} else {
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, 0, screenW, highlightHeight + 2);
|
||||
display->setColor(WHITE);
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(0, 20, screenW, 20);
|
||||
} else {
|
||||
display->drawLine(0, 14, screenW, 14);
|
||||
display->fillRect(0, 0, screenW, headerHeight);
|
||||
// Keep the legacy white separator for monochrome displays only when header background is visible.
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (!transparent_background) {
|
||||
display->setColor(WHITE);
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(0, 20, screenW, 20);
|
||||
} else {
|
||||
display->drawLine(0, 14, screenW, 14);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (transparent_background) {
|
||||
display->setColor(WHITE);
|
||||
}
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// TFT role coloring expects foreground glyph bits to be "set".
|
||||
display->setColor(WHITE);
|
||||
#endif
|
||||
|
||||
// === Screen Title ===
|
||||
const char *headerTitle = titleStr ? titleStr : "";
|
||||
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
|
||||
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2);
|
||||
registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL);
|
||||
#endif
|
||||
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
|
||||
}
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
@@ -158,6 +232,17 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
|
||||
bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH);
|
||||
const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
|
||||
bool hasBatteryFillRegion = false;
|
||||
int16_t batteryFillRegionX = 0;
|
||||
int16_t batteryFillRegionY = 0;
|
||||
int16_t batteryFillRegionW = 0;
|
||||
int16_t batteryFillRegionH = 0;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent);
|
||||
if (useClockHeaderAccent) {
|
||||
batteryFillColor = getThemeHeaderBg();
|
||||
}
|
||||
#endif
|
||||
|
||||
int batteryX = 1;
|
||||
int batteryY = HEADER_OFFSET_Y + 1;
|
||||
@@ -186,6 +271,15 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12);
|
||||
int fillWidth = 14 * chargePercent / 100;
|
||||
display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (fillWidth > 0) {
|
||||
hasBatteryFillRegion = true;
|
||||
batteryFillRegionX = batteryX + 1;
|
||||
batteryFillRegionY = batteryY + 1;
|
||||
batteryFillRegionW = fillWidth;
|
||||
batteryFillRegionH = 11;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
batteryX += 18; // Icon + 2 pixels
|
||||
} else {
|
||||
@@ -200,21 +294,41 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int fillHeight = 8 * chargePercent / 100;
|
||||
int fillY = batteryY - fillHeight;
|
||||
display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (fillHeight > 0) {
|
||||
hasBatteryFillRegion = true;
|
||||
batteryFillRegionX = batteryX + 1;
|
||||
batteryFillRegionY = fillY + 10;
|
||||
batteryFillRegionW = 5;
|
||||
batteryFillRegionH = fillHeight;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
batteryX += 9; // Icon + 2 pixels
|
||||
}
|
||||
}
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = batteryX + 2;
|
||||
#endif
|
||||
|
||||
if (chargePercent != 101) {
|
||||
// === Battery % Display ===
|
||||
char chargeStr[4];
|
||||
snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent);
|
||||
int chargeNumWidth = display->getStringWidth(chargeStr);
|
||||
const int percentWidth = display->getStringWidth("%");
|
||||
const int percentX = batteryX + chargeNumWidth - 1;
|
||||
display->drawString(batteryX, textY, chargeStr);
|
||||
display->drawString(batteryX + chargeNumWidth - 1, textY, "%");
|
||||
display->drawString(percentX, textY, "%");
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = percentX + percentWidth + 2;
|
||||
#endif
|
||||
if (isBold) {
|
||||
display->drawString(batteryX + 1, textY, chargeStr);
|
||||
display->drawString(batteryX + chargeNumWidth, textY, "%");
|
||||
display->drawString(percentX + 1, textY, "%");
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = percentX + percentWidth + 3;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +373,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
timeStrWidth = display->getStringWidth(timeStr);
|
||||
}
|
||||
timeX = screenW - xOffset - timeStrWidth + 3;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16);
|
||||
#endif
|
||||
|
||||
// === Show Mail or Mute Icon to the Left of Time ===
|
||||
int iconRightEdge = timeX - 2;
|
||||
@@ -284,7 +401,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int iconW = 16, iconH = 12;
|
||||
int iconX = iconRightEdge - iconW;
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -299,7 +416,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
} else {
|
||||
int iconX = iconRightEdge - (mail_width - 2);
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -315,7 +432,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int iconX = iconRightEdge - mute_symbol_big_width;
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
|
||||
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -329,7 +446,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
int iconX = iconRightEdge - mute_symbol_width;
|
||||
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
|
||||
|
||||
if (isInverted && !force_no_invert) {
|
||||
if (useInvertedHeaderStyle) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -357,7 +474,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
} else {
|
||||
// === No Time Available: Mail/Mute Icon Moves to Far Right ===
|
||||
int iconRightEdge = screenW - xOffset;
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12);
|
||||
#endif
|
||||
bool showMail = false;
|
||||
|
||||
#ifndef USE_EINK
|
||||
@@ -399,6 +518,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight);
|
||||
if (statusRightStartX < screenW) {
|
||||
registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight);
|
||||
}
|
||||
if (hasBatteryFillRegion) {
|
||||
registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH,
|
||||
batteryFillColor, headerColorForRoles);
|
||||
}
|
||||
#endif
|
||||
display->setColor(WHITE); // Reset for other UI
|
||||
}
|
||||
@@ -436,14 +565,23 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
return;
|
||||
|
||||
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
|
||||
const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale);
|
||||
const int footerH = (connection_icon_height * scale) + (2 * scale);
|
||||
const int iconX = 0;
|
||||
const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale);
|
||||
const int iconW = connection_icon_width * scale;
|
||||
const int iconH = connection_icon_height * scale;
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// Only tint the link glyph itself on TFT; keep the footer background black.
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH);
|
||||
#endif
|
||||
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
|
||||
(connection_icon_height * scale) + (2 * scale));
|
||||
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
|
||||
display->setColor(WHITE);
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
const int bytesPerRow = (connection_icon_width + 7) / 8;
|
||||
int iconX = 0;
|
||||
int iconY = SCREEN_HEIGHT - (connection_icon_height * 2);
|
||||
|
||||
for (int yy = 0; yy < connection_icon_height; ++yy) {
|
||||
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
|
||||
@@ -457,65 +595,127 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
}
|
||||
|
||||
} else {
|
||||
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
|
||||
connection_icon);
|
||||
display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon);
|
||||
}
|
||||
}
|
||||
|
||||
bool isAllowedPunctuation(char c)
|
||||
{
|
||||
const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ ";
|
||||
return allowed.find(c) != std::string::npos;
|
||||
switch (c) {
|
||||
case '.':
|
||||
case ',':
|
||||
case '!':
|
||||
case '?':
|
||||
case ';':
|
||||
case ':':
|
||||
case '-':
|
||||
case '_':
|
||||
case '(':
|
||||
case ')':
|
||||
case '[':
|
||||
case ']':
|
||||
case '{':
|
||||
case '}':
|
||||
case '\'':
|
||||
case '"':
|
||||
case '@':
|
||||
case '#':
|
||||
case '$':
|
||||
case '/':
|
||||
case '\\':
|
||||
case '&':
|
||||
case '+':
|
||||
case '=':
|
||||
case '%':
|
||||
case '~':
|
||||
case '^':
|
||||
case ' ':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void replaceAll(std::string &s, const std::string &from, const std::string &to)
|
||||
static inline size_t utf8CodePointLength(unsigned char lead)
|
||||
{
|
||||
if (from.empty())
|
||||
return;
|
||||
size_t pos = 0;
|
||||
while ((pos = s.find(from, pos)) != std::string::npos) {
|
||||
s.replace(pos, from.size(), to);
|
||||
pos += to.size();
|
||||
if ((lead & 0x80) == 0x00) {
|
||||
return 1;
|
||||
}
|
||||
if ((lead & 0xE0) == 0xC0) {
|
||||
return 2;
|
||||
}
|
||||
if ((lead & 0xF0) == 0xE0) {
|
||||
return 3;
|
||||
}
|
||||
if ((lead & 0xF8) == 0xF0) {
|
||||
return 4;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string sanitizeString(const std::string &input)
|
||||
{
|
||||
static constexpr char kReplacementChar = static_cast<char>(0xBF); // Inverted question mark in ISO-8859-1.
|
||||
std::string output;
|
||||
output.reserve(input.size());
|
||||
bool inReplacement = false;
|
||||
|
||||
// Make a mutable copy so we can normalize UTF-8 “smart punctuation” into ASCII first.
|
||||
std::string s = input;
|
||||
|
||||
// Curly single quotes: ‘ ’
|
||||
replaceAll(s, "\xE2\x80\x98", "'"); // U+2018
|
||||
replaceAll(s, "\xE2\x80\x99", "'"); // U+2019
|
||||
|
||||
// Curly double quotes: “ ”
|
||||
replaceAll(s, "\xE2\x80\x9C", "\""); // U+201C
|
||||
replaceAll(s, "\xE2\x80\x9D", "\""); // U+201D
|
||||
|
||||
// En dash / Em dash: – —
|
||||
replaceAll(s, "\xE2\x80\x93", "-"); // U+2013
|
||||
replaceAll(s, "\xE2\x80\x94", "-"); // U+2014
|
||||
|
||||
// Non-breaking space
|
||||
replaceAll(s, "\xC2\xA0", " "); // U+00A0
|
||||
|
||||
// Now do your original sanitize pass over the normalized string.
|
||||
for (unsigned char uc : s) {
|
||||
char c = static_cast<char>(uc);
|
||||
if (std::isalnum(uc) || isAllowedPunctuation(c)) {
|
||||
output += c;
|
||||
inReplacement = false;
|
||||
} else {
|
||||
const size_t inputSize = input.size();
|
||||
size_t i = 0;
|
||||
while (i < inputSize) {
|
||||
const unsigned char byte0 = static_cast<unsigned char>(input[i]);
|
||||
char normalized = '\0';
|
||||
size_t consumed = 0;
|
||||
if (byte0 < 0x80) {
|
||||
normalized = static_cast<char>(byte0);
|
||||
consumed = 1;
|
||||
} else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast<unsigned char>(input[i + 1]) == 0x80) {
|
||||
// Smart punctuation: ' ' \" \" - -
|
||||
switch (static_cast<unsigned char>(input[i + 2])) {
|
||||
case 0x98:
|
||||
case 0x99:
|
||||
normalized = '\'';
|
||||
consumed = 3;
|
||||
break;
|
||||
case 0x9C:
|
||||
case 0x9D:
|
||||
normalized = '\"';
|
||||
consumed = 3;
|
||||
break;
|
||||
case 0x93:
|
||||
case 0x94:
|
||||
normalized = '-';
|
||||
consumed = 3;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast<unsigned char>(input[i + 1]) == 0xA0) {
|
||||
// Non-breaking space.
|
||||
normalized = ' ';
|
||||
consumed = 2;
|
||||
}
|
||||
if (consumed == 0) {
|
||||
size_t seqLen = utf8CodePointLength(byte0);
|
||||
if (seqLen > (inputSize - i)) {
|
||||
seqLen = 1;
|
||||
}
|
||||
if (!inReplacement) {
|
||||
output += static_cast<char>(0xBF); // ISO-8859-1 for inverted question mark
|
||||
output.push_back(kReplacementChar);
|
||||
inReplacement = true;
|
||||
}
|
||||
i += seqLen;
|
||||
continue;
|
||||
}
|
||||
const unsigned char normalizedUc = static_cast<unsigned char>(normalized);
|
||||
if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) {
|
||||
output.push_back(normalized);
|
||||
inReplacement = false;
|
||||
} else if (!inReplacement) {
|
||||
output.push_back(kReplacementChar);
|
||||
inReplacement = true;
|
||||
}
|
||||
i += consumed;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <OLEDDisplay.h>
|
||||
#include <stdint.h>
|
||||
#include <string>
|
||||
|
||||
namespace graphics
|
||||
@@ -52,7 +53,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
|
||||
|
||||
// Shared battery/time/mail header
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false,
|
||||
bool show_date = false);
|
||||
bool show_date = false, bool transparent_background = false, bool use_title_color_override = false,
|
||||
uint16_t title_color_override = 0);
|
||||
|
||||
// Shared battery/time/mail header
|
||||
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
#include "TFTColorRegions.h"
|
||||
#include "NodeDB.h"
|
||||
#include "TFTPalette.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
struct TFTRoleColorsBe {
|
||||
uint16_t onColorBe;
|
||||
uint16_t offColorBe;
|
||||
};
|
||||
|
||||
static uint8_t colorRegionCount = 0;
|
||||
static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u;
|
||||
static constexpr uint32_t kFnv1aPrime = 16777619u;
|
||||
|
||||
static constexpr uint16_t toBe565(uint16_t color)
|
||||
{
|
||||
return static_cast<uint16_t>((color >> 8) | (color << 8));
|
||||
}
|
||||
|
||||
static constexpr bool kRoleIsBody[static_cast<size_t>(TFTColorRole::Count)] = {
|
||||
false, // HeaderBackground
|
||||
false, // HeaderTitle
|
||||
false, // HeaderStatus
|
||||
true, // SignalBars
|
||||
true, // ConnectionIcon
|
||||
true, // UtilizationFill
|
||||
true, // FavoriteNode
|
||||
true, // ActionMenuBorder
|
||||
true, // ActionMenuBody
|
||||
true, // ActionMenuTitle
|
||||
true, // FrameMono
|
||||
false, // BootSplash
|
||||
true, // FavoriteNodeBGHighlight
|
||||
false, // NavigationBar
|
||||
false // NavigationArrow
|
||||
};
|
||||
|
||||
static inline bool isBodyColorRole(TFTColorRole role)
|
||||
{
|
||||
return kRoleIsBody[static_cast<size_t>(role)];
|
||||
}
|
||||
|
||||
static inline bool isMonochromeTheme(uint32_t themeId)
|
||||
{
|
||||
return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
|
||||
}
|
||||
|
||||
static inline uint16_t getMonochromeAccent(uint32_t themeId)
|
||||
{
|
||||
return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen
|
||||
: (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed
|
||||
: TFTPalette::White;
|
||||
}
|
||||
|
||||
static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to)
|
||||
{
|
||||
if (value == from) {
|
||||
value = to;
|
||||
}
|
||||
}
|
||||
|
||||
static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value)
|
||||
{
|
||||
return (hash ^ value) * kFnv1aPrime;
|
||||
}
|
||||
|
||||
static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value)
|
||||
{
|
||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>(value & 0xFF));
|
||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>((value >> 8) & 0xFF));
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Compile-time header color overrides (backward-compatible)
|
||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kTitleColor = TFTPalette::White;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kStatusColor = TFTPalette::White;
|
||||
#endif
|
||||
|
||||
// Theme definitions
|
||||
// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color
|
||||
// against each entry's .uniqueIdentifier field.
|
||||
|
||||
static const TFTThemeDef kThemes[] = {
|
||||
|
||||
// Default Dark (ThemeID::DefaultDark = 0)
|
||||
{
|
||||
ThemeID::DefaultDark, // id
|
||||
"Default Dark", // name
|
||||
0, // uniqueIdentifier
|
||||
// roles[TFTColorRole::Count]
|
||||
{
|
||||
{kHeaderBackground, TFTPalette::Black}, // HeaderBackground
|
||||
{kHeaderBackground, kTitleColor}, // HeaderTitle
|
||||
{kHeaderBackground, kStatusColor}, // HeaderStatus
|
||||
{TFTPalette::Good, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::Good, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg)
|
||||
{kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::Good, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
false, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Default Light (ThemeID::DefaultLight = 1)
|
||||
{
|
||||
ThemeID::DefaultLight, // id
|
||||
"Default Light", // name
|
||||
1, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::Good, TFTPalette::White}, // SignalBars
|
||||
{TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon
|
||||
{TFTPalette::Good, TFTPalette::White}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode
|
||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::White}, // ActionMenuBody
|
||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::Good, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Christmas (ThemeID::Christmas = 2)
|
||||
{
|
||||
ThemeID::Christmas, // id
|
||||
"Christmas", // name
|
||||
2, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus
|
||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon
|
||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody
|
||||
{TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Pine, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::ChristmasGreen, // batteryFillGood
|
||||
TFTPalette::Gold, // batteryFillMedium
|
||||
TFTPalette::ChristmasRed, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
false, // visible
|
||||
},
|
||||
|
||||
// Pink (ThemeID::Pink = 3) light variant
|
||||
{
|
||||
ThemeID::Pink, // id
|
||||
"Pink", // name
|
||||
3, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus
|
||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon
|
||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::HotPink}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::DeepPink, // batteryFillGood
|
||||
TFTPalette::HotPink, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Blue (ThemeID::Blue = 4) dark variant
|
||||
{
|
||||
ThemeID::Blue, // id
|
||||
"Blue", // name
|
||||
4, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode
|
||||
{TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody
|
||||
{TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Navy, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::SkyBlue, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Creamsicle (ThemeID::Creamsicle = 5)light variant
|
||||
{
|
||||
ThemeID::Creamsicle, // id
|
||||
"Creamsicle", // name
|
||||
5, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus
|
||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars
|
||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon
|
||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode
|
||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::DeepOrange, // batteryFillGood
|
||||
TFTPalette::Gold, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome
|
||||
// Pure single-color-on-black look. Every role maps foreground pixels to
|
||||
// the theme color and background pixels to Black.
|
||||
{
|
||||
ThemeID::MeshtasticGreen, // id
|
||||
"Meshtastic Green", // name
|
||||
6, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Classic Red (ThemeID::ClassicRed = 7) classic monochrome
|
||||
{
|
||||
ThemeID::ClassicRed, // id
|
||||
"Classic Red", // name
|
||||
7, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome
|
||||
{
|
||||
ThemeID::MonochromeWhite, // id
|
||||
"Monochrome White", // name
|
||||
8, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::White, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::White, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::White, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::White, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
};
|
||||
|
||||
static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]);
|
||||
|
||||
// Resolve the kThemes[] index for the currently persisted theme. Called at
|
||||
// boot (indirectly via getActiveTheme()) and whenever the active theme is
|
||||
// queried, so uiconfig.screen_rgb_color remains the single source of truth.
|
||||
// Matches against .uniqueIdentifier - that's the field whose value is stored
|
||||
// in the user's config. Falls back to 0 (DefaultDark) if no match is found,
|
||||
// which gracefully handles removed or retired themes.
|
||||
static inline size_t resolveThemeIndex()
|
||||
{
|
||||
const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (kThemes[i].uniqueIdentifier == savedIdentifier)
|
||||
return i;
|
||||
}
|
||||
return 0; // Default Dark fallback
|
||||
}
|
||||
|
||||
static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (x < 0) {
|
||||
width += x;
|
||||
x = 0;
|
||||
}
|
||||
if (y < 0) {
|
||||
height += y;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
|
||||
static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe)
|
||||
{
|
||||
// Keep the last slot permanently disabled as a sentinel for ST7789 scans.
|
||||
// This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries.
|
||||
if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) {
|
||||
memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2));
|
||||
colorRegionCount = MAX_TFT_COLOR_REGIONS - 2;
|
||||
}
|
||||
|
||||
TFTColorRegion ®ion = colorRegions[colorRegionCount++];
|
||||
region.x = x;
|
||||
region.y = y;
|
||||
region.width = width;
|
||||
region.height = height;
|
||||
region.onColorBe = onColorBe;
|
||||
region.offColorBe = offColorBe;
|
||||
region.enabled = true;
|
||||
|
||||
// Keep one disabled sentinel after the active range for ST7789 countColorRegions().
|
||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
||||
colorRegions[colorRegionCount].enabled = false;
|
||||
}
|
||||
colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false;
|
||||
}
|
||||
|
||||
// Current working role colors (big-endian). Initialised to Dark defaults;
|
||||
// call loadThemeDefaults() after boot / theme change to refresh.
|
||||
static TFTRoleColorsBe roleColors[static_cast<size_t>(TFTColorRole::Count)] = {
|
||||
{toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground
|
||||
{toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle
|
||||
{toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus
|
||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars
|
||||
{toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon
|
||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill
|
||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode
|
||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder
|
||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody
|
||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle
|
||||
{toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono
|
||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash
|
||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight
|
||||
{toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar
|
||||
{toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Theme accessors
|
||||
|
||||
const TFTThemeDef &getActiveTheme()
|
||||
{
|
||||
return kThemes[resolveThemeIndex()];
|
||||
}
|
||||
|
||||
// Visible-theme accessors
|
||||
// These iterate only themes flagged .visible = true, preserving kThemes[]
|
||||
// order. Menu code should use these so hidden themes don't appear in the
|
||||
// picker while still applying correctly if their ID is persisted.
|
||||
|
||||
size_t getVisibleThemeCount()
|
||||
{
|
||||
size_t count = 0;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (kThemes[i].visible)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex)
|
||||
{
|
||||
size_t seen = 0;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (!kThemes[i].visible)
|
||||
continue;
|
||||
if (seen == visibleIndex)
|
||||
return kThemes[i];
|
||||
seen++;
|
||||
}
|
||||
// Fallback: return first theme (never trust a bad index).
|
||||
return kThemes[0];
|
||||
}
|
||||
|
||||
size_t getActiveVisibleThemeIndex()
|
||||
{
|
||||
const size_t active = resolveThemeIndex();
|
||||
if (!kThemes[active].visible)
|
||||
return SIZE_MAX;
|
||||
size_t visibleIdx = 0;
|
||||
for (size_t i = 0; i < active; i++) {
|
||||
if (kThemes[i].visible)
|
||||
visibleIdx++;
|
||||
}
|
||||
return visibleIdx;
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderBg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
||||
return TFT_HEADER_BG_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderBackground)].onColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::DarkGray;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderText()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
||||
return TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderTitle)].offColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderStatus()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
||||
return TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderStatus)].offColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBodyBg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].onColor;
|
||||
#else
|
||||
return TFTPalette::Black;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBodyFg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].offColor;
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isThemeFullFrameInvert()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].fullFrameInvert;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBatteryFillColor(int batteryPercent)
|
||||
{
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
if (batteryPercent <= 20) {
|
||||
return theme.batteryFillBad;
|
||||
}
|
||||
if (batteryPercent <= 50) {
|
||||
return theme.batteryFillMedium;
|
||||
}
|
||||
return theme.batteryFillGood;
|
||||
}
|
||||
|
||||
void loadThemeDefaults()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
for (uint8_t i = 0; i < static_cast<uint8_t>(TFTColorRole::Count); i++) {
|
||||
roleColors[i].onColorBe = toBe565(theme.roles[i].onColor);
|
||||
roleColors[i].offColorBe = toBe565(theme.roles[i].offColor);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Role color assignment with theme-aware transforms
|
||||
|
||||
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
const uint8_t index = static_cast<uint8_t>(role);
|
||||
if (index >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F;
|
||||
const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight);
|
||||
const bool isBodyRole = !isHighlightRole && isBodyColorRole(role);
|
||||
|
||||
// Classic monochrome themes collapse all non-black accents into one tone.
|
||||
if (isMonochromeTheme(themeId)) {
|
||||
if (onColor != TFTPalette::Black) {
|
||||
onColor = getMonochromeAccent(themeId);
|
||||
}
|
||||
} else {
|
||||
switch (themeId) {
|
||||
case ThemeID::DefaultLight:
|
||||
if (isHighlightRole) {
|
||||
// High-contrast highlight chips on light UI.
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::Yellow;
|
||||
} else if (isBodyRole) {
|
||||
// Invert body colors for readability on white frames.
|
||||
if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) {
|
||||
offColor = TFTPalette::White;
|
||||
}
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Christmas:
|
||||
if (isHighlightRole || isBodyRole) {
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold);
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Pink:
|
||||
if (isHighlightRole) {
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::HotPink;
|
||||
} else if (isBodyRole) {
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink);
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Creamsicle:
|
||||
if (isHighlightRole) {
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::CreamOrange;
|
||||
} else if (isBodyRole) {
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream);
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Blue:
|
||||
if (isHighlightRole || isBodyRole) {
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue);
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
roleColors[index].onColorBe = toBe565(onColor);
|
||||
roleColors[index].offColorBe = toBe565(offColor);
|
||||
}
|
||||
|
||||
// Region registration
|
||||
|
||||
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
const uint8_t roleIndex = static_cast<uint8_t>(role);
|
||||
if (roleIndex >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizeRegion(x, y, width, height)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const TFTRoleColorsBe &colors = roleColors[roleIndex];
|
||||
appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe);
|
||||
}
|
||||
|
||||
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
|
||||
int16_t height)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)role;
|
||||
(void)onColor;
|
||||
(void)offColor;
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)width;
|
||||
(void)height;
|
||||
return;
|
||||
#else
|
||||
setTFTColorRole(role, onColor, offColor);
|
||||
registerTFTColorRegion(role, x, y, width, height);
|
||||
#endif
|
||||
}
|
||||
|
||||
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
if (!normalizeRegion(x, y, width, height))
|
||||
return;
|
||||
|
||||
appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor));
|
||||
}
|
||||
|
||||
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)boxLeft;
|
||||
(void)boxTop;
|
||||
(void)boxWidth;
|
||||
(void)boxHeight;
|
||||
return;
|
||||
#else
|
||||
// Use theme-appropriate menu colors.
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
const TFTThemeRoleColor &menuBody = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBody)];
|
||||
const TFTThemeRoleColor &menuBorder = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBorder)];
|
||||
|
||||
// Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly.
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1,
|
||||
boxWidth + 2, boxHeight + 2);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight);
|
||||
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth,
|
||||
1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Frame signature & utilities
|
||||
|
||||
uint32_t getTFTColorFrameSignature()
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return 0;
|
||||
#else
|
||||
uint32_t hash = kFnv1aOffsetBasis;
|
||||
hash = fnv1aAppendByte(hash, colorRegionCount);
|
||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
||||
const TFTColorRegion &r = colorRegions[i];
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.x));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.y));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.width));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.height));
|
||||
hash = fnv1aAppendU16(hash, r.onColorBe);
|
||||
hash = fnv1aAppendU16(hash, r.offColorBe);
|
||||
}
|
||||
|
||||
return hash;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint8_t getTFTColorRegionCount()
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return 0;
|
||||
#else
|
||||
return colorRegionCount;
|
||||
#endif
|
||||
}
|
||||
|
||||
void clearTFTColorRegions()
|
||||
{
|
||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
||||
colorRegions[i].enabled = false;
|
||||
}
|
||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
||||
colorRegions[colorRegionCount].enabled = false;
|
||||
}
|
||||
colorRegionCount = 0;
|
||||
}
|
||||
|
||||
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
|
||||
{
|
||||
for (int i = static_cast<int>(colorRegionCount) - 1; i >= 0; i--) {
|
||||
const TFTColorRegion &r = colorRegions[i];
|
||||
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
|
||||
return isset ? r.onColorBe : r.offColorBe;
|
||||
}
|
||||
}
|
||||
return isset ? defaultOnColor : defaultOffColor;
|
||||
}
|
||||
|
||||
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)x;
|
||||
(void)y;
|
||||
return defaultOffColor;
|
||||
#else
|
||||
const uint16_t defaultOffBe = toBe565(defaultOffColor);
|
||||
const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe);
|
||||
return static_cast<uint16_t>((sampledBe >> 8) | (sampledBe << 8));
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
@@ -0,0 +1,163 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
#include <stdint.h>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
|
||||
struct TFTColorRegion {
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
uint16_t onColorBe;
|
||||
uint16_t offColorBe;
|
||||
// Required by ST7789 driver: it scans until the first disabled entry.
|
||||
bool enabled = false;
|
||||
};
|
||||
|
||||
static constexpr size_t MAX_TFT_COLOR_REGIONS = 48;
|
||||
extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
|
||||
|
||||
enum class TFTColorRole : uint8_t {
|
||||
HeaderBackground = 0,
|
||||
HeaderTitle,
|
||||
HeaderStatus,
|
||||
SignalBars,
|
||||
ConnectionIcon,
|
||||
UtilizationFill,
|
||||
FavoriteNode,
|
||||
ActionMenuBorder,
|
||||
ActionMenuBody,
|
||||
ActionMenuTitle,
|
||||
FrameMono,
|
||||
BootSplash,
|
||||
FavoriteNodeBGHighlight,
|
||||
NavigationBar,
|
||||
NavigationArrow,
|
||||
Count
|
||||
};
|
||||
|
||||
#if HAS_TFT || defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \
|
||||
defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) || \
|
||||
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)
|
||||
#define GRAPHICS_TFT_COLORING_ENABLED 1
|
||||
#else
|
||||
#define GRAPHICS_TFT_COLORING_ENABLED 0
|
||||
#endif
|
||||
|
||||
static constexpr bool kTFTColoringEnabled = GRAPHICS_TFT_COLORING_ENABLED != 0;
|
||||
constexpr bool isTFTColoringEnabled()
|
||||
{
|
||||
return kTFTColoringEnabled;
|
||||
}
|
||||
|
||||
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor);
|
||||
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height);
|
||||
// Convenience helper for the common "set role then register one region" flow.
|
||||
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
|
||||
int16_t height);
|
||||
// Register a region using explicit colors (no role lookup). Use when the
|
||||
// color comes from a theme field rather than a role (e.g. battery fill).
|
||||
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor);
|
||||
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight);
|
||||
uint32_t getTFTColorFrameSignature();
|
||||
uint8_t getTFTColorRegionCount();
|
||||
void clearTFTColorRegions();
|
||||
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor);
|
||||
// Resolve effective region-mapped OFF color at a coordinate in native-endian RGB565.
|
||||
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor);
|
||||
|
||||
// -- Theme engine ------------------------------------------------------
|
||||
// Each theme has four fields that work together:
|
||||
//
|
||||
// id - ThemeID:: constant, used for in-code references.
|
||||
// name - human-readable label shown in the theme picker.
|
||||
// uniqueIdentifier - the stable numeric value persisted to
|
||||
// uiconfig.screen_rgb_color and restored at boot.
|
||||
// This is a CONTRACT with saved configs on disk - once
|
||||
// assigned, never reuse or renumber, even if the theme is
|
||||
// deleted or the kThemes[] array is reordered.
|
||||
// visible - controls whether a theme appears in the picker menu.
|
||||
// Hidden themes can still be restored and applied if their
|
||||
// uniqueIdentifier is persisted.
|
||||
//
|
||||
// Display order in the menu is controlled by kThemes[] array order among
|
||||
// themes where visible == true, NOT by any numeric value above.
|
||||
//
|
||||
// To add a new theme:
|
||||
// 1. Add a unique constant in ThemeID below (next unused value).
|
||||
// 2. Add a kThemes[] entry at the desired menu position, with a unique
|
||||
// uniqueIdentifier that has never been used by any prior theme.
|
||||
// 3. Set visible=true if it should appear in the picker.
|
||||
//
|
||||
// To retire a theme without breaking saved configs:
|
||||
// - Preferred: keep the entry and set visible=false so existing saved
|
||||
// uniqueIdentifier values still resolve to the same theme.
|
||||
// - If you remove the entry, resolveThemeIndex() falls back to DefaultDark
|
||||
// when the persisted uniqueIdentifier no longer matches any theme.
|
||||
// - Do NOT reuse a retired uniqueIdentifier for a future theme.
|
||||
namespace ThemeID
|
||||
{
|
||||
constexpr uint32_t DefaultDark = 0;
|
||||
constexpr uint32_t DefaultLight = 1;
|
||||
constexpr uint32_t Christmas = 2;
|
||||
constexpr uint32_t Pink = 3;
|
||||
constexpr uint32_t Blue = 4;
|
||||
constexpr uint32_t Creamsicle = 5;
|
||||
constexpr uint32_t MeshtasticGreen = 6;
|
||||
constexpr uint32_t ClassicRed = 7;
|
||||
constexpr uint32_t MonochromeWhite = 8;
|
||||
} // namespace ThemeID
|
||||
|
||||
// Per-role color pair stored in native (little-endian) RGB565 format.
|
||||
struct TFTThemeRoleColor {
|
||||
uint16_t onColor;
|
||||
uint16_t offColor;
|
||||
};
|
||||
|
||||
// Complete theme definition.
|
||||
struct TFTThemeDef {
|
||||
uint32_t id; // ThemeID constant - in-code identifier for this theme.
|
||||
const char *name; // Human-readable label shown in the theme picker.
|
||||
uint32_t uniqueIdentifier; // Stable persisted value copied into uiconfig.screen_rgb_color.
|
||||
// Never reuse or renumber - see file-level notes above.
|
||||
TFTThemeRoleColor roles[static_cast<size_t>(TFTColorRole::Count)];
|
||||
uint16_t batteryFillGood;
|
||||
uint16_t batteryFillMedium;
|
||||
uint16_t batteryFillBad;
|
||||
bool fullFrameInvert; // Apply full-frame FrameMono inversion (ST7789 light themes)
|
||||
bool visible; // Show in the theme picker menu. Hidden themes still apply
|
||||
// correctly if their uniqueIdentifier is persisted (dev/legacy themes).
|
||||
};
|
||||
|
||||
// Count of themes whose .visible flag is true. Use this when building menus.
|
||||
size_t getVisibleThemeCount();
|
||||
|
||||
// Access the Nth visible theme (0 .. getVisibleThemeCount()-1). Hidden themes
|
||||
// are skipped, preserving kThemes[] order among the visible entries.
|
||||
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex);
|
||||
|
||||
// Return the theme that matches uiconfig.screen_rgb_color (falls back to Dark).
|
||||
const TFTThemeDef &getActiveTheme();
|
||||
|
||||
// Return the visible-theme index for the currently active theme, or SIZE_MAX
|
||||
// if the active theme is hidden (so menus can show "no selection").
|
||||
size_t getActiveVisibleThemeIndex();
|
||||
|
||||
// Convenience accessors - safe to call even when coloring is compiled out.
|
||||
uint16_t getThemeHeaderBg();
|
||||
uint16_t getThemeHeaderText();
|
||||
uint16_t getThemeHeaderStatus();
|
||||
uint16_t getThemeBodyBg();
|
||||
uint16_t getThemeBodyFg();
|
||||
bool isThemeFullFrameInvert();
|
||||
uint16_t getThemeBatteryFillColor(int batteryPercent);
|
||||
|
||||
// Reinitialise default roleColors from the active theme. Call after a
|
||||
// theme change so that any role registered without a prior setTFTColorRole()
|
||||
// picks up theme-appropriate defaults.
|
||||
void loadThemeDefaults();
|
||||
|
||||
} // namespace graphics
|
||||
+145
-38
@@ -16,12 +16,6 @@
|
||||
extern SX1509 gpioExtender;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_MESH_OVERRIDE
|
||||
uint16_t TFT_MESH = TFT_MESH_OVERRIDE;
|
||||
#else
|
||||
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
|
||||
#endif
|
||||
|
||||
#if defined(ST7735S)
|
||||
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
|
||||
|
||||
@@ -1151,7 +1145,9 @@ static LGFX *tft = nullptr;
|
||||
#endif
|
||||
|
||||
#include "SPILock.h"
|
||||
#include "TFTColorRegions.h"
|
||||
#include "TFTDisplay.h"
|
||||
#include "TFTPalette.h"
|
||||
#include <SPI.h>
|
||||
|
||||
#ifdef UNPHONE
|
||||
@@ -1161,6 +1157,25 @@ extern unPhone unphone;
|
||||
|
||||
GpioPin *TFTDisplay::backlightEnable = NULL;
|
||||
|
||||
namespace
|
||||
{
|
||||
static constexpr uint8_t kFullRepaintChunkRows = 8;
|
||||
|
||||
static inline uint16_t getThemeDefaultOnColor()
|
||||
{
|
||||
return graphics::TFTPalette::White;
|
||||
}
|
||||
|
||||
static inline uint16_t getThemeDefaultOffColor()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return graphics::getThemeBodyBg();
|
||||
#else
|
||||
return TFT_BLACK;
|
||||
#endif
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
|
||||
{
|
||||
LOG_DEBUG("TFTDisplay!");
|
||||
@@ -1200,14 +1215,15 @@ TFTDisplay::~TFTDisplay()
|
||||
free(linePixelBuffer);
|
||||
linePixelBuffer = nullptr;
|
||||
}
|
||||
if (repaintChunkBuffer != nullptr) {
|
||||
free(repaintChunkBuffer);
|
||||
repaintChunkBuffer = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the buffer to the display memory
|
||||
void TFTDisplay::display(bool fromBlank)
|
||||
{
|
||||
if (fromBlank)
|
||||
tft->fillScreen(TFT_BLACK);
|
||||
|
||||
concurrency::LockGuard g(spiLock);
|
||||
|
||||
uint32_t x, y;
|
||||
@@ -1216,12 +1232,70 @@ void TFTDisplay::display(bool fromBlank)
|
||||
uint32_t x_FirstPixelUpdate;
|
||||
uint32_t x_LastPixelUpdate;
|
||||
bool isset, dblbuf_isset;
|
||||
uint16_t colorTftMesh, colorTftBlack;
|
||||
uint16_t colorTftWhite, colorTftBlack;
|
||||
bool somethingChanged = false;
|
||||
|
||||
// Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step
|
||||
colorTftMesh = __builtin_bswap16(TFT_MESH);
|
||||
colorTftBlack = __builtin_bswap16(TFT_BLACK);
|
||||
// Theme defaults for non-role pixels.
|
||||
const uint16_t defaultOnColor = getThemeDefaultOnColor();
|
||||
const uint16_t defaultOffColor = getThemeDefaultOffColor();
|
||||
static uint16_t lastDefaultOnColor = 0;
|
||||
static uint16_t lastDefaultOffColor = 0;
|
||||
static bool haveLastDefaults = false;
|
||||
const bool themeDefaultsChanged =
|
||||
!haveLastDefaults || (defaultOnColor != lastDefaultOnColor) || (defaultOffColor != lastDefaultOffColor);
|
||||
const bool forceFullRepaint = fromBlank || themeDefaultsChanged;
|
||||
|
||||
// If theme defaults changed, reset panel background immediately so stale pixels don't linger.
|
||||
if (forceFullRepaint) {
|
||||
tft->fillScreen(defaultOffColor);
|
||||
}
|
||||
|
||||
colorTftWhite = (defaultOnColor >> 8) | ((defaultOnColor & 0xFF) << 8);
|
||||
colorTftBlack = (defaultOffColor >> 8) | ((defaultOffColor & 0xFF) << 8);
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
static uint32_t lastColorFrameSignature = 0;
|
||||
const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
|
||||
const uint32_t colorFrameSignature = graphics::getTFTColorFrameSignature();
|
||||
const bool forceFullColorRepaint = forceFullRepaint || (colorFrameSignature != lastColorFrameSignature);
|
||||
|
||||
// When region roles/layout changed, color can differ even with identical monochrome glyph bits.
|
||||
// Repaint full frame only for those frames, then return to diff-based updates.
|
||||
if (forceFullColorRepaint) {
|
||||
for (uint32_t yStart = 0; yStart < displayHeight; yStart += kFullRepaintChunkRows) {
|
||||
const uint32_t rowsThisChunk = min<uint32_t>(kFullRepaintChunkRows, displayHeight - yStart);
|
||||
for (uint32_t row = 0; row < rowsThisChunk; row++) {
|
||||
y = yStart + row;
|
||||
y_byteIndex = (y / 8) * displayWidth;
|
||||
y_byteMask = (1 << (y & 7));
|
||||
|
||||
uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth);
|
||||
for (x = 0; x < displayWidth; x++) {
|
||||
isset = (buffer[x + y_byteIndex] & y_byteMask) != 0;
|
||||
if (hasColorRegions) {
|
||||
chunkRow[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
|
||||
colorTftWhite, colorTftBlack);
|
||||
} else {
|
||||
chunkRow[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
}
|
||||
}
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk);
|
||||
#else
|
||||
tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer);
|
||||
#endif
|
||||
}
|
||||
|
||||
memcpy(buffer_back, buffer, displayBufferSize);
|
||||
lastColorFrameSignature = colorFrameSignature;
|
||||
haveLastDefaults = true;
|
||||
lastDefaultOnColor = defaultOnColor;
|
||||
lastDefaultOffColor = defaultOffColor;
|
||||
graphics::clearTFTColorRegions();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
y = 0;
|
||||
while (y < displayHeight) {
|
||||
@@ -1230,7 +1304,7 @@ void TFTDisplay::display(bool fromBlank)
|
||||
|
||||
// Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas.
|
||||
if (y_byteMask == 1) {
|
||||
if (!fromBlank) {
|
||||
if (!forceFullRepaint) {
|
||||
for (x = 0; x < displayWidth; x++) {
|
||||
if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex])
|
||||
break;
|
||||
@@ -1248,13 +1322,14 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating
|
||||
for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) {
|
||||
isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
|
||||
// Step 2: Scan this row for changed span (first and last changed pixel).
|
||||
uint32_t x_FirstChanged = 0;
|
||||
for (x_FirstChanged = 0; x_FirstChanged < displayWidth; x_FirstChanged++) {
|
||||
isset = buffer[x_FirstChanged + y_byteIndex] & y_byteMask;
|
||||
|
||||
if (!fromBlank) {
|
||||
if (!forceFullRepaint) {
|
||||
// get src pixel in the page based ordering the OLED lib uses
|
||||
dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
|
||||
dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask;
|
||||
if (isset != dblbuf_isset) {
|
||||
break;
|
||||
}
|
||||
@@ -1264,26 +1339,42 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
|
||||
// Did we find a pixel that needs updating on this row?
|
||||
if (x_FirstPixelUpdate < displayWidth) {
|
||||
|
||||
// Quickly write out the first changed pixel (saves another array lookup)
|
||||
linePixelBuffer[x_FirstPixelUpdate] = isset ? colorTftMesh : colorTftBlack;
|
||||
x_LastPixelUpdate = x_FirstPixelUpdate;
|
||||
|
||||
// Step 3: copy all remaining pixels in this row into the pixel line buffer,
|
||||
// while also recording the last pixel in the row that needs updating
|
||||
for (x = x_FirstPixelUpdate + 1; x < displayWidth; x++) {
|
||||
isset = buffer[x + y_byteIndex] & y_byteMask;
|
||||
linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack;
|
||||
|
||||
if (!fromBlank) {
|
||||
dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask;
|
||||
if (x_FirstChanged < displayWidth) {
|
||||
uint32_t x_LastChanged = displayWidth - 1;
|
||||
while (x_LastChanged > x_FirstChanged) {
|
||||
isset = buffer[x_LastChanged + y_byteIndex] & y_byteMask;
|
||||
if (!forceFullRepaint) {
|
||||
dblbuf_isset = buffer_back[x_LastChanged + y_byteIndex] & y_byteMask;
|
||||
if (isset != dblbuf_isset) {
|
||||
x_LastPixelUpdate = x;
|
||||
break;
|
||||
}
|
||||
} else if (isset) {
|
||||
x_LastPixelUpdate = x;
|
||||
break;
|
||||
}
|
||||
x_LastChanged--;
|
||||
}
|
||||
|
||||
// Align the first pixel for update to an even number so the total alignment of
|
||||
// the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
|
||||
x_FirstPixelUpdate = x_FirstChanged & ~1U;
|
||||
x_LastPixelUpdate = x_LastChanged | 1U;
|
||||
if (x_LastPixelUpdate >= displayWidth) {
|
||||
x_LastPixelUpdate = displayWidth - 1;
|
||||
}
|
||||
|
||||
// Step 3: Copy only the changed span into the pixel line buffer.
|
||||
for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
|
||||
isset = buffer[x + y_byteIndex] & y_byteMask;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (hasColorRegions) {
|
||||
linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
|
||||
colorTftWhite, colorTftBlack);
|
||||
} else {
|
||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
}
|
||||
#else
|
||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
#endif
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
|
||||
@@ -1291,8 +1382,8 @@ void TFTDisplay::display(bool fromBlank)
|
||||
#else
|
||||
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
|
||||
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
|
||||
tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||
tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||
#endif
|
||||
somethingChanged = true;
|
||||
}
|
||||
@@ -1301,6 +1392,14 @@ void TFTDisplay::display(bool fromBlank)
|
||||
// Copy the Buffer to the Back Buffer
|
||||
if (somethingChanged)
|
||||
memcpy(buffer_back, buffer, displayBufferSize);
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
lastColorFrameSignature = colorFrameSignature;
|
||||
#endif
|
||||
haveLastDefaults = true;
|
||||
lastDefaultOnColor = defaultOnColor;
|
||||
lastDefaultOffColor = defaultOffColor;
|
||||
graphics::clearTFTColorRegions();
|
||||
}
|
||||
|
||||
void TFTDisplay::sdlLoop()
|
||||
@@ -1514,7 +1613,7 @@ bool TFTDisplay::connect()
|
||||
#else
|
||||
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
|
||||
#endif
|
||||
tft->fillScreen(TFT_BLACK);
|
||||
tft->fillScreen(getThemeDefaultOffColor());
|
||||
|
||||
if (this->linePixelBuffer == NULL) {
|
||||
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
|
||||
@@ -1524,6 +1623,14 @@ bool TFTDisplay::connect()
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this->repaintChunkBuffer == NULL) {
|
||||
this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows);
|
||||
|
||||
if (!this->repaintChunkBuffer) {
|
||||
LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,4 +63,5 @@ class TFTDisplay : public OLEDDisplay
|
||||
virtual bool connect() override;
|
||||
|
||||
uint16_t *linePixelBuffer = nullptr;
|
||||
};
|
||||
uint16_t *repaintChunkBuffer = nullptr;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
namespace TFTPalette
|
||||
{
|
||||
|
||||
constexpr uint16_t rgb565(uint8_t red, uint8_t green, uint8_t blue)
|
||||
{
|
||||
return static_cast<uint16_t>(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | ((blue & 0xF8) >> 3));
|
||||
}
|
||||
|
||||
constexpr uint16_t Black = 0x0000;
|
||||
constexpr uint16_t White = 0xFFFF;
|
||||
constexpr uint16_t DarkGray = 0x4208;
|
||||
constexpr uint16_t Gray = 0x8410;
|
||||
constexpr uint16_t LightGray = 0xC618;
|
||||
|
||||
constexpr uint16_t Red = rgb565(255, 0, 0);
|
||||
constexpr uint16_t Green = rgb565(0, 255, 0);
|
||||
constexpr uint16_t Blue = rgb565(0, 130, 252);
|
||||
constexpr uint16_t Yellow = rgb565(255, 255, 0);
|
||||
constexpr uint16_t Orange = rgb565(255, 165, 0);
|
||||
constexpr uint16_t Cyan = rgb565(0, 255, 255);
|
||||
constexpr uint16_t Magenta = rgb565(255, 0, 255);
|
||||
|
||||
constexpr uint16_t Good = Green;
|
||||
constexpr uint16_t Medium = Yellow;
|
||||
constexpr uint16_t Bad = Red;
|
||||
|
||||
// Christmas / seasonal accent colors
|
||||
constexpr uint16_t ChristmasRed = rgb565(178, 34, 34);
|
||||
constexpr uint16_t ChristmasGreen = rgb565(0, 128, 0);
|
||||
constexpr uint16_t Gold = rgb565(255, 215, 0);
|
||||
constexpr uint16_t Pine = rgb565(15, 35, 10);
|
||||
|
||||
// Pink theme colors (light variant)
|
||||
constexpr uint16_t HotPink = rgb565(255, 105, 180);
|
||||
constexpr uint16_t PalePink = rgb565(255, 228, 235);
|
||||
constexpr uint16_t DeepPink = rgb565(200, 50, 120);
|
||||
|
||||
// Blue theme colors (dark variant)
|
||||
constexpr uint16_t SkyBlue = rgb565(100, 180, 255);
|
||||
constexpr uint16_t Navy = rgb565(15, 15, 50);
|
||||
constexpr uint16_t DeepBlue = rgb565(30, 60, 120);
|
||||
|
||||
// Creamsicle theme colors (light variant)
|
||||
constexpr uint16_t CreamOrange = rgb565(255, 140, 50);
|
||||
constexpr uint16_t DeepOrange = rgb565(220, 100, 20);
|
||||
constexpr uint16_t Cream = rgb565(255, 248, 235);
|
||||
|
||||
// Classic monochrome theme accent colors (single-color-on-black themes)
|
||||
constexpr uint16_t MeshtasticGreen = rgb565(0x67, 0xEA, 0x94);
|
||||
constexpr uint16_t ClassicRed = rgb565(255, 64, 64);
|
||||
// Monochrome White reuses TFTPalette::White above.
|
||||
|
||||
// Fast contrast picker for monochrome glyph overlays on arbitrary RGB565 backgrounds.
|
||||
// Uses channel-sum brightness approximation to keep code size small.
|
||||
constexpr uint16_t pickReadableMonoFg(uint16_t backgroundColor)
|
||||
{
|
||||
const uint16_t r = (backgroundColor >> 11) & 0x1F;
|
||||
const uint16_t g = (backgroundColor >> 5) & 0x3F;
|
||||
const uint16_t b = backgroundColor & 0x1F;
|
||||
return ((r + g + b) >= 70) ? DarkGray : White;
|
||||
}
|
||||
|
||||
} // namespace TFTPalette
|
||||
} // namespace graphics
|
||||
@@ -142,8 +142,9 @@ void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offset
|
||||
if (keyboardStartY < 0)
|
||||
keyboardStartY = 0;
|
||||
} else {
|
||||
// Default (non-wide, non-64px) behavior: use key height heuristic and place at bottom
|
||||
cellH = KEY_HEIGHT;
|
||||
// Default (non-wide, non-64px) e.g. SH1107 128x128:
|
||||
// cellH = FONT_HEIGHT_SMALL - 2 so rows are tighter while still hosting the font
|
||||
cellH = std::max((int)KEY_HEIGHT, FONT_HEIGHT_SMALL - 2);
|
||||
int keyboardHeight = KEYBOARD_ROWS * cellH;
|
||||
keyboardStartY = screenH - keyboardHeight;
|
||||
if (keyboardStartY < 0)
|
||||
@@ -446,11 +447,8 @@ void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool
|
||||
if (textX < x)
|
||||
textX = x; // guard
|
||||
} else {
|
||||
if (display->getHeight() <= 64 && (key.character >= '0' && key.character <= '9')) {
|
||||
textX = x + (width - textWidth + 1) / 2;
|
||||
} else {
|
||||
textX = x + (width - textWidth) / 2;
|
||||
}
|
||||
// Use ceil rounding for all screens (consistent with 128x64 behavior for numbers)
|
||||
textX = x + (width - textWidth + 1) / 2;
|
||||
}
|
||||
int contentTop = y;
|
||||
int contentH = height;
|
||||
@@ -746,4 +744,4 @@ bool VirtualKeyboard::isTimedOut() const
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -145,7 +145,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
// === Set Title, Blank for Clock
|
||||
const char *titleStr = "";
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
|
||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
|
||||
|
||||
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
|
||||
char timeString[16];
|
||||
@@ -293,11 +293,15 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
// Draw an analog clock
|
||||
void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// Clear previous frame pixels so moving hands don't leave stale artifacts on TFT light theme.
|
||||
display->clear();
|
||||
#endif
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
// === Set Title, Blank for Clock
|
||||
const char *titleStr = "";
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
|
||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
|
||||
|
||||
// clock face center coordinates
|
||||
int16_t centerX = display->getWidth() / 2;
|
||||
@@ -478,4 +482,4 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
} // namespace ClockRenderer
|
||||
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#include "CompassRenderer.h"
|
||||
#include "NodeDB.h"
|
||||
#include "UIRenderer.h"
|
||||
#include "configuration.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include <cmath>
|
||||
@@ -13,111 +9,103 @@ namespace graphics
|
||||
{
|
||||
namespace CompassRenderer
|
||||
{
|
||||
|
||||
// Point helper class for compass calculations
|
||||
struct Point {
|
||||
float x, y;
|
||||
Point(float x, float y) : x(x), y(y) {}
|
||||
|
||||
void rotate(float angle)
|
||||
{
|
||||
float cos_a = cos(angle);
|
||||
float sin_a = sin(angle);
|
||||
float new_x = x * cos_a - y * sin_a;
|
||||
float new_y = x * sin_a + y * cos_a;
|
||||
x = new_x;
|
||||
y = new_y;
|
||||
}
|
||||
|
||||
void scale(float factor)
|
||||
{
|
||||
x *= factor;
|
||||
y *= factor;
|
||||
}
|
||||
|
||||
void translate(float dx, float dy)
|
||||
{
|
||||
x += dx;
|
||||
y += dy;
|
||||
}
|
||||
};
|
||||
|
||||
void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
|
||||
{
|
||||
// Show the compass heading (not implemented in original)
|
||||
// This could draw a "N" indicator or north arrow
|
||||
// For now, we'll draw a simple north indicator
|
||||
// const float radius = 17.0f;
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
radius += 4;
|
||||
}
|
||||
Point north(0, -radius);
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
north.rotate(-myHeading);
|
||||
north.translate(compassX, compassY);
|
||||
|
||||
const float northAngle = (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -myHeading : 0.0f;
|
||||
const int16_t nX = compassX + static_cast<int16_t>((radius - 1) * sinf(northAngle));
|
||||
const int16_t nY = compassY - static_cast<int16_t>((radius - 1) * cosf(northAngle));
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
display->setColor(BLACK);
|
||||
const int16_t nLabelWidth = display->getStringWidth("N");
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->fillRect(north.x - 8, north.y - 1, display->getStringWidth("N") + 3, FONT_HEIGHT_SMALL - 6);
|
||||
display->fillRect(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
|
||||
} else {
|
||||
display->fillRect(north.x - 4, north.y - 1, display->getStringWidth("N") + 2, FONT_HEIGHT_SMALL - 6);
|
||||
display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
|
||||
}
|
||||
display->setColor(WHITE);
|
||||
display->drawString(north.x, north.y - 3, "N");
|
||||
}
|
||||
|
||||
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
|
||||
{
|
||||
Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially
|
||||
float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f;
|
||||
Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY);
|
||||
|
||||
Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
arrowPoints[i]->rotate(headingRadian);
|
||||
arrowPoints[i]->scale(compassDiam * 0.6);
|
||||
arrowPoints[i]->translate(compassX, compassY);
|
||||
}
|
||||
|
||||
#ifdef USE_EINK
|
||||
display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
|
||||
#else
|
||||
display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
|
||||
#endif
|
||||
display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
|
||||
display->setColor(WHITE);
|
||||
display->drawString(nX, nY - 3, "N");
|
||||
}
|
||||
|
||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
|
||||
{
|
||||
float radians = bearing * DEG_TO_RAD;
|
||||
const float radians = bearing * DEG_TO_RAD;
|
||||
const float sinA = sinf(radians);
|
||||
const float cosA = cosf(radians);
|
||||
const float tipHalf = size * 0.5f;
|
||||
const float lx = -(size / 6.0f);
|
||||
const float ly = size / 4.0f;
|
||||
const float rx = (size / 6.0f);
|
||||
const float ry = size / 4.0f;
|
||||
const float tx = 0.0f;
|
||||
const float ty = size / 4.5f;
|
||||
|
||||
Point tip(0, -size / 2);
|
||||
Point left(-size / 6, size / 4);
|
||||
Point right(size / 6, size / 4);
|
||||
Point tail(0, size / 4.5);
|
||||
const int16_t tipX = static_cast<int16_t>(x + (tipHalf * sinA));
|
||||
const int16_t tipY = static_cast<int16_t>(y - (tipHalf * cosA));
|
||||
const int16_t leftX = static_cast<int16_t>(x + (lx * cosA) - (ly * sinA));
|
||||
const int16_t leftY = static_cast<int16_t>(y + (lx * sinA) + (ly * cosA));
|
||||
const int16_t rightX = static_cast<int16_t>(x + (rx * cosA) - (ry * sinA));
|
||||
const int16_t rightY = static_cast<int16_t>(y + (rx * sinA) + (ry * cosA));
|
||||
const int16_t tailX = static_cast<int16_t>(x + (tx * cosA) - (ty * sinA));
|
||||
const int16_t tailY = static_cast<int16_t>(y + (tx * sinA) + (ty * cosA));
|
||||
|
||||
tip.rotate(radians);
|
||||
left.rotate(radians);
|
||||
right.rotate(radians);
|
||||
tail.rotate(radians);
|
||||
|
||||
tip.translate(x, y);
|
||||
left.translate(x, y);
|
||||
right.translate(x, y);
|
||||
tail.translate(x, y);
|
||||
|
||||
display->fillTriangle(tip.x, tip.y, left.x, left.y, tail.x, tail.y);
|
||||
display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y);
|
||||
display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY);
|
||||
display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY);
|
||||
}
|
||||
|
||||
float estimatedHeading(double lat, double lon)
|
||||
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
|
||||
{
|
||||
// Simple magnetic declination estimation
|
||||
// This is a very basic implementation - the original might be more sophisticated
|
||||
return 0.0f; // Return 0 for now, indicating no heading available
|
||||
const int16_t size = static_cast<int16_t>(compassDiam * 0.6f);
|
||||
drawArrowToNode(display, compassX, compassY, size, headingRadian * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
bool getHeadingRadians(double lat, double lon, float &headingRadian)
|
||||
{
|
||||
headingRadian = 0.0f;
|
||||
|
||||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING)
|
||||
return true;
|
||||
|
||||
if (!screen)
|
||||
return false;
|
||||
|
||||
if (screen->hasHeading()) {
|
||||
headingRadian = screen->getHeading() * DEG_TO_RAD;
|
||||
return true;
|
||||
}
|
||||
|
||||
const float estimatedHeadingDeg = screen->estimatedHeading(lat, lon);
|
||||
if (!(estimatedHeadingDeg >= 0.0f))
|
||||
return false;
|
||||
|
||||
headingRadian = estimatedHeadingDeg * DEG_TO_RAD;
|
||||
return true;
|
||||
}
|
||||
|
||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian)
|
||||
{
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
return bearingRadian - headingRadian;
|
||||
|
||||
return bearingRadian;
|
||||
}
|
||||
|
||||
float radiansToDegrees360(float angleRadian)
|
||||
{
|
||||
constexpr float fullTurnDeg = 360.0f;
|
||||
float degrees = angleRadian * RAD_TO_DEG;
|
||||
if (degrees < 0.0f)
|
||||
degrees += fullTurnDeg;
|
||||
else if (degrees >= fullTurnDeg)
|
||||
degrees -= fullTurnDeg;
|
||||
return degrees;
|
||||
}
|
||||
|
||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
||||
@@ -137,4 +125,4 @@ uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
||||
|
||||
} // namespace CompassRenderer
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -25,7 +25,9 @@ void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, u
|
||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing);
|
||||
|
||||
// Navigation and location functions
|
||||
float estimatedHeading(double lat, double lon);
|
||||
bool getHeadingRadians(double lat, double lon, float &headingRadian);
|
||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian);
|
||||
float radiansToDegrees360(float angleRadian);
|
||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
|
||||
|
||||
} // namespace CompassRenderer
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/TimeFormatters.h"
|
||||
#include "graphics/images.h"
|
||||
#include "main.h"
|
||||
@@ -408,7 +410,16 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
display->drawString(nameX, getTextPositions(display)[line++], device_role);
|
||||
|
||||
// === Third Row: Radio Preset ===
|
||||
auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
|
||||
// For custom modem settings show the actual parameters; for presets use the preset name.
|
||||
char modeStr[16];
|
||||
if (!config.lora.use_preset) {
|
||||
snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast<unsigned>(config.lora.bandwidth),
|
||||
static_cast<unsigned>(config.lora.spread_factor), static_cast<unsigned>(config.lora.coding_rate));
|
||||
} else {
|
||||
strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true),
|
||||
sizeof(modeStr) - 1);
|
||||
modeStr[sizeof(modeStr) - 1] = '\0';
|
||||
}
|
||||
|
||||
char regionradiopreset[25];
|
||||
const char *region = myRegion ? myRegion->name : NULL;
|
||||
@@ -416,7 +427,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region);
|
||||
} else {
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode);
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr);
|
||||
}
|
||||
}
|
||||
textWidth = display->getStringWidth(regionradiopreset);
|
||||
@@ -460,9 +471,11 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
int chUtil_y = getTextPositions(display)[line] + 3;
|
||||
|
||||
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
||||
int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border
|
||||
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
|
||||
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
|
||||
int chutil_percent = airTime->channelUtilizationPercent();
|
||||
const int raw_chutil_percent = chutil_percent;
|
||||
|
||||
int centerofscreen = SCREEN_WIDTH / 2;
|
||||
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
|
||||
@@ -470,7 +483,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
|
||||
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
||||
|
||||
// Force 56% or higher to show a full 100% bar, text would still show related percent.
|
||||
// Force 61% or higher to show a full 100% bar, text would still show related percent.
|
||||
if (chutil_percent >= 61) {
|
||||
chutil_percent = 100;
|
||||
}
|
||||
@@ -483,9 +496,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
float weight3 = 0.20; // Weight for 40–100%
|
||||
float totalWeight = weight1 + weight2 + weight3;
|
||||
|
||||
int seg1 = chutil_bar_width * (weight1 / totalWeight);
|
||||
int seg2 = chutil_bar_width * (weight2 / totalWeight);
|
||||
int seg3 = chutil_bar_width * (weight3 / totalWeight);
|
||||
int seg1 = chutil_bar_max_fill * (weight1 / totalWeight);
|
||||
int seg2 = chutil_bar_max_fill * (weight2 / totalWeight);
|
||||
int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors
|
||||
|
||||
int fillRight = 0;
|
||||
|
||||
@@ -502,7 +515,17 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
|
||||
// Fill progress
|
||||
if (fillRight > 0) {
|
||||
display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
uint16_t UtilizationFillColor = TFTPalette::Good;
|
||||
if (raw_chutil_percent >= 60) {
|
||||
UtilizationFillColor = TFTPalette::Bad;
|
||||
} else if (raw_chutil_percent >= 35) {
|
||||
UtilizationFillColor = TFTPalette::Medium;
|
||||
}
|
||||
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black,
|
||||
starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
#endif
|
||||
display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
}
|
||||
|
||||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++],
|
||||
@@ -575,6 +598,17 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
display->setColor(WHITE);
|
||||
display->drawRect(barX, barY, adjustedBarWidth, barHeight);
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
uint16_t UtilizationFillColor = TFTPalette::Good;
|
||||
if (percent >= 80) {
|
||||
UtilizationFillColor = TFTPalette::Bad;
|
||||
} else if (percent >= 60) {
|
||||
UtilizationFillColor = TFTPalette::Medium;
|
||||
}
|
||||
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, barX + 1, barY + 1,
|
||||
fillWidth - 1, barHeight - 2);
|
||||
#endif
|
||||
|
||||
display->fillRect(barX, barY, fillWidth, barHeight);
|
||||
display->setColor(WHITE);
|
||||
#endif
|
||||
|
||||
+242
-227
@@ -2,6 +2,7 @@
|
||||
#if HAS_SCREEN
|
||||
#include "ClockRenderer.h"
|
||||
#include "Default.h"
|
||||
#include "DisplayFormatters.h"
|
||||
#include "GPS.h"
|
||||
#include "MenuHandler.h"
|
||||
#include "MeshRadio.h"
|
||||
@@ -11,6 +12,7 @@
|
||||
#include "buzz.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/draw/MessageRenderer.h"
|
||||
#include "graphics/draw/UIRenderer.h"
|
||||
#include "input/RotaryEncoderInterruptImpl1.h"
|
||||
@@ -18,6 +20,7 @@
|
||||
#include "main.h"
|
||||
#include "mesh/Default.h"
|
||||
#include "mesh/MeshTypes.h"
|
||||
#include "mesh/RadioLibInterface.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
@@ -25,11 +28,10 @@
|
||||
#include "modules/TraceRouteModule.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
|
||||
extern uint16_t TFT_MESH;
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
|
||||
@@ -56,6 +58,70 @@ BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOp
|
||||
return bannerOptions;
|
||||
}
|
||||
|
||||
const StoredMessage *getNewestMessageForActiveThread()
|
||||
{
|
||||
const auto &messages = messageStore.getMessages();
|
||||
if (messages.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto mode = graphics::MessageRenderer::getThreadMode();
|
||||
const int channel = graphics::MessageRenderer::getThreadChannel();
|
||||
const uint32_t peer = graphics::MessageRenderer::getThreadPeer();
|
||||
const uint32_t localNode = nodeDB->getNodeNum();
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
|
||||
return &messages.back();
|
||||
}
|
||||
|
||||
for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
|
||||
const StoredMessage &m = *it;
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
||||
if (m.type == MessageType::BROADCAST && static_cast<int>(m.channelIndex) == channel) {
|
||||
return &m;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
||||
if (m.type != MessageType::DM_TO_US) {
|
||||
continue;
|
||||
}
|
||||
const uint32_t other = (m.sender == localNode) ? m.dest : m.sender;
|
||||
if (other == peer) {
|
||||
return &m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void launchReplyForMessage(const StoredMessage &message, bool freetext)
|
||||
{
|
||||
if (message.type == MessageType::BROADCAST || message.dest == NODENUM_BROADCAST) {
|
||||
if (freetext) {
|
||||
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, message.channelIndex);
|
||||
} else {
|
||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, message.channelIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t localNode = nodeDB->getNodeNum();
|
||||
const uint32_t peer = (message.sender == localNode) ? message.dest : message.sender;
|
||||
if (peer == 0 || peer == NODENUM_BROADCAST) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (freetext) {
|
||||
cannedMessageModule->LaunchFreetextWithDestination(peer);
|
||||
} else {
|
||||
cannedMessageModule->LaunchWithDestination(peer);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
|
||||
@@ -115,6 +181,8 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
{"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US},
|
||||
{"EU_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_433},
|
||||
{"EU_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_868},
|
||||
{"EU_866", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_866},
|
||||
{"EU_868_NARROW", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_N_868},
|
||||
{"CN", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_CN},
|
||||
{"JP", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_JP},
|
||||
{"ANZ", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ANZ},
|
||||
@@ -138,6 +206,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
{"KZ_863", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KZ_863},
|
||||
{"NP_865", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_NP_865},
|
||||
{"BR_902", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_BR_902},
|
||||
|
||||
};
|
||||
|
||||
constexpr size_t regionCount = sizeof(regionOptions) / sizeof(regionOptions[0]);
|
||||
@@ -159,36 +228,27 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: without a reboot, reconfigure() applies the region directly.
|
||||
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
|
||||
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora
|
||||
// rather than a hardcoded check for LORA_24.
|
||||
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
|
||||
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
|
||||
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection");
|
||||
return;
|
||||
}
|
||||
|
||||
config.lora.region = selectedRegion;
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
// FIXME: This should be a method consolidated with the same logic in the admin message as well
|
||||
// This is needed as we wait til picking the LoRa region to generate keys for the first time.
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (!owner.is_licensed) {
|
||||
bool keygenSuccess = false;
|
||||
if (config.security.private_key.size == 32) {
|
||||
// public key is derived from private, so this will always have the same result.
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
} else {
|
||||
LOG_INFO("Generate new PKI keys");
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
}
|
||||
#endif
|
||||
config.lora.tx_enabled = true;
|
||||
initRegion();
|
||||
if (myRegion->dutyCycle < 100) {
|
||||
if (getEffectiveDutyCycle() < 100) {
|
||||
config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
||||
}
|
||||
|
||||
@@ -199,7 +259,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
}
|
||||
|
||||
service->reloadConfig(changes);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
});
|
||||
|
||||
bannerOptions.durationMs = duration;
|
||||
@@ -265,13 +324,24 @@ void menuHandler::FrequencySlotPicker()
|
||||
optionsEnumArray[options++] = 0;
|
||||
|
||||
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
|
||||
|
||||
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
|
||||
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
|
||||
: bwCodeToKHz(loraConfig.bandwidth);
|
||||
|
||||
uint32_t numChannels = 0;
|
||||
if (myRegion) {
|
||||
numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0)));
|
||||
// Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round()
|
||||
const double spacing = myRegion->profile->spacing;
|
||||
const double padding = myRegion->profile->padding;
|
||||
const double channelBandwidthMHz = bw / 1000.0;
|
||||
const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing;
|
||||
const double denominator = spacing + (padding * 2) + channelBandwidthMHz;
|
||||
if (denominator > 0.0) {
|
||||
numChannels = static_cast<uint32_t>(round(numerator / denominator));
|
||||
} else {
|
||||
LOG_WARN("Invalid region configuration: non-positive channel spacing/width");
|
||||
}
|
||||
} else {
|
||||
LOG_WARN("Region not set, cannot calculate number of channels");
|
||||
return;
|
||||
@@ -307,49 +377,69 @@ void menuHandler::FrequencySlotPicker()
|
||||
|
||||
config.lora.channel_num = selected;
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
};
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
// Maximum presets any region can have + 1 for Back
|
||||
static constexpr int MAX_PRESET_OPTIONS = 16;
|
||||
|
||||
static BannerOverlayOptions buildRegionPresetBanner()
|
||||
{
|
||||
// Static storage reused each call — safe because the banner is shown immediately after.
|
||||
static const char *optionsArray[MAX_PRESET_OPTIONS];
|
||||
static int optionsEnumArray[MAX_PRESET_OPTIONS];
|
||||
static char presetLabelBuf[MAX_PRESET_OPTIONS][12]; // scratch space for name copies
|
||||
int count = 0;
|
||||
|
||||
optionsArray[count] = "Back";
|
||||
optionsEnumArray[count++] = -1;
|
||||
|
||||
if (myRegion && myRegion->profile) {
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets();
|
||||
size_t numPresets = myRegion->getNumPresets();
|
||||
for (size_t i = 0; i < numPresets && count < MAX_PRESET_OPTIONS; ++i) {
|
||||
const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true);
|
||||
strncpy(presetLabelBuf[count], name, sizeof(presetLabelBuf[count]) - 1);
|
||||
presetLabelBuf[count][sizeof(presetLabelBuf[count]) - 1] = '\0';
|
||||
optionsArray[count] = presetLabelBuf[count];
|
||||
optionsEnumArray[count++] = static_cast<int>(presets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
int initialSelection = 0;
|
||||
for (int i = 1; i < count; ++i) {
|
||||
if (optionsEnumArray[i] == static_cast<int>(config.lora.modem_preset)) {
|
||||
initialSelection = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Radio Preset";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
bannerOptions.optionsCount = static_cast<uint8_t>(count);
|
||||
bannerOptions.InitialSelected = initialSelection;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
if (selected == -1) {
|
||||
menuHandler::menuQueue = menuHandler::LoraMenu;
|
||||
screen->runNow();
|
||||
return;
|
||||
}
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(selected);
|
||||
config.lora.channel_num = 0; // Reset to default channel for the preset
|
||||
config.lora.override_frequency = 0; // Clear any custom frequency
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
};
|
||||
return bannerOptions;
|
||||
}
|
||||
|
||||
void menuHandler::radioPresetPicker()
|
||||
{
|
||||
static const RadioPresetOption presetOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{"LongTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO},
|
||||
{"LongModerate", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE},
|
||||
{"LongFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST},
|
||||
{"MediumSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW},
|
||||
{"MediumFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST},
|
||||
{"ShortSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW},
|
||||
{"ShortFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST},
|
||||
{"ShortTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO},
|
||||
};
|
||||
|
||||
constexpr size_t presetCount = sizeof(presetOptions) / sizeof(presetOptions[0]);
|
||||
static std::array<const char *, presetCount> presetLabels{};
|
||||
|
||||
auto bannerOptions =
|
||||
createStaticBannerOptions("Radio Preset", presetOptions, presetLabels, [](const RadioPresetOption &option, int) -> void {
|
||||
if (option.action == OptionsAction::Back) {
|
||||
menuHandler::menuQueue = menuHandler::LoraMenu;
|
||||
screen->runNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!option.hasValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.lora.modem_preset = option.value;
|
||||
config.lora.channel_num = 0; // Reset to default channel for the preset
|
||||
config.lora.override_frequency = 0; // Clear any custom frequency
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
});
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
screen->showOverlayBanner(buildRegionPresetBanner());
|
||||
}
|
||||
|
||||
void menuHandler::twelveHourPicker()
|
||||
@@ -594,9 +684,12 @@ void menuHandler::messageResponseMenu()
|
||||
|
||||
#ifdef HAS_I2S
|
||||
} else if (selected == Aloud) {
|
||||
const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
|
||||
const char *msg = reinterpret_cast<const char *>(mp.decoded.payload.bytes);
|
||||
audioThread->readAloud(msg);
|
||||
if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
||||
const char *msg = MessageStore::getText(*latest);
|
||||
if (msg && msg[0]) {
|
||||
audioThread->readAloud(msg);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
};
|
||||
@@ -656,20 +749,12 @@ void menuHandler::replyMenu()
|
||||
|
||||
// Preset reply
|
||||
if (selected == ReplyPreset) {
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, ch);
|
||||
|
||||
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
||||
cannedMessageModule->LaunchWithDestination(peer);
|
||||
|
||||
} else {
|
||||
// Fallback for last received message
|
||||
if (devicestate.rx_text_message.to == NODENUM_BROADCAST) {
|
||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel);
|
||||
} else {
|
||||
cannedMessageModule->LaunchWithDestination(devicestate.rx_text_message.from);
|
||||
}
|
||||
} else if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
||||
launchReplyForMessage(*latest, false);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -677,20 +762,12 @@ void menuHandler::replyMenu()
|
||||
|
||||
// Freetext reply
|
||||
if (selected == ReplyFreetext) {
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
||||
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, ch);
|
||||
|
||||
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
||||
cannedMessageModule->LaunchFreetextWithDestination(peer);
|
||||
|
||||
} else {
|
||||
// Fallback for last received message
|
||||
if (devicestate.rx_text_message.to == NODENUM_BROADCAST) {
|
||||
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel);
|
||||
} else {
|
||||
cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from);
|
||||
}
|
||||
} else if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
||||
launchReplyForMessage(*latest, true);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -845,10 +922,10 @@ void menuHandler::messageViewModeMenu()
|
||||
// Encode peers
|
||||
for (size_t i = 0; i < uniquePeers.size(); ++i) {
|
||||
uint32_t peer = uniquePeers[i];
|
||||
auto node = nodeDB->getMeshNode(peer);
|
||||
const auto *node = nodeDB->getMeshNode(peer);
|
||||
std::string name;
|
||||
if (node && node->has_user)
|
||||
name = sanitizeString(node->user.long_name).substr(0, 15);
|
||||
if (nodeInfoLiteHasUser(node))
|
||||
name = sanitizeString(node->long_name).substr(0, 15);
|
||||
else {
|
||||
char buf[20];
|
||||
snprintf(buf, sizeof(buf), "Node %08X", peer);
|
||||
@@ -1355,14 +1432,14 @@ void menuHandler::manageNodeMenu()
|
||||
static int optionsEnumArray[enumEnd] = {Back};
|
||||
int options = 1;
|
||||
|
||||
if (node->is_favorite) {
|
||||
if (nodeInfoLiteIsFavorite(node)) {
|
||||
optionsArray[options] = "Unfavorite";
|
||||
} else {
|
||||
optionsArray[options] = "Favorite";
|
||||
}
|
||||
optionsEnumArray[options++] = Favorite;
|
||||
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||
if (isMuted) {
|
||||
optionsArray[options] = "Unmute Notifications";
|
||||
} else {
|
||||
@@ -1376,7 +1453,7 @@ void menuHandler::manageNodeMenu()
|
||||
optionsArray[options] = "Key Verification";
|
||||
optionsEnumArray[options++] = KeyVerification;
|
||||
|
||||
if (node->is_ignored) {
|
||||
if (nodeInfoLiteIsIgnored(node)) {
|
||||
optionsArray[options] = "Unignore Node";
|
||||
} else {
|
||||
optionsArray[options] = "Ignore Node";
|
||||
@@ -1386,8 +1463,8 @@ void menuHandler::manageNodeMenu()
|
||||
BannerOverlayOptions bannerOptions;
|
||||
|
||||
std::string title = "";
|
||||
if (node->has_user && node->user.long_name && node->user.long_name[0]) {
|
||||
title += sanitizeString(node->user.long_name).substr(0, 15);
|
||||
if (nodeInfoLiteHasUser(node) && node->long_name[0]) {
|
||||
title += sanitizeString(node->long_name).substr(0, 15);
|
||||
} else {
|
||||
char buf[20];
|
||||
snprintf(buf, sizeof(buf), "%08X", (unsigned int)node->num);
|
||||
@@ -1409,7 +1486,7 @@ void menuHandler::manageNodeMenu()
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
if (n->is_favorite) {
|
||||
if (nodeInfoLiteIsFavorite(n)) {
|
||||
LOG_INFO("Removing node %08X from favorites", menuHandler::pickedNodeNum);
|
||||
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
|
||||
} else {
|
||||
@@ -1426,13 +1503,9 @@ void menuHandler::manageNodeMenu()
|
||||
return;
|
||||
}
|
||||
|
||||
if (n->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) {
|
||||
n->bitfield &= ~NODEINFO_BITFIELD_IS_MUTED_MASK;
|
||||
LOG_INFO("Unmuted node %08X", menuHandler::pickedNodeNum);
|
||||
} else {
|
||||
n->bitfield |= NODEINFO_BITFIELD_IS_MUTED_MASK;
|
||||
LOG_INFO("Muted node %08X", menuHandler::pickedNodeNum);
|
||||
}
|
||||
const bool wasMuted = nodeInfoLiteIsMuted(n);
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
|
||||
LOG_INFO(wasMuted ? "Unmuted node %08X" : "Muted node %08X", menuHandler::pickedNodeNum);
|
||||
nodeDB->notifyObservers(true);
|
||||
nodeDB->saveToDisk();
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
||||
@@ -1461,11 +1534,11 @@ void menuHandler::manageNodeMenu()
|
||||
return;
|
||||
}
|
||||
|
||||
if (n->is_ignored) {
|
||||
n->is_ignored = false;
|
||||
if (nodeInfoLiteIsIgnored(n)) {
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
|
||||
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
|
||||
} else {
|
||||
n->is_ignored = true;
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
|
||||
}
|
||||
nodeDB->notifyObservers(true);
|
||||
@@ -2027,109 +2100,6 @@ void menuHandler::switchToMUIMenu()
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
void menuHandler::TFTColorPickerMenu(OLEDDisplay *display)
|
||||
{
|
||||
static const ScreenColorOption colorOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{"Default", OptionsAction::Select, ScreenColor(0, 0, 0, true)},
|
||||
{"Meshtastic Green", OptionsAction::Select, ScreenColor(0x67, 0xEA, 0x94)},
|
||||
{"Yellow", OptionsAction::Select, ScreenColor(255, 255, 128)},
|
||||
{"Red", OptionsAction::Select, ScreenColor(255, 64, 64)},
|
||||
{"Orange", OptionsAction::Select, ScreenColor(255, 160, 20)},
|
||||
{"Purple", OptionsAction::Select, ScreenColor(204, 153, 255)},
|
||||
{"Blue", OptionsAction::Select, ScreenColor(0, 0, 255)},
|
||||
{"Teal", OptionsAction::Select, ScreenColor(16, 102, 102)},
|
||||
{"Cyan", OptionsAction::Select, ScreenColor(0, 255, 255)},
|
||||
{"Ice", OptionsAction::Select, ScreenColor(173, 216, 230)},
|
||||
{"Pink", OptionsAction::Select, ScreenColor(255, 105, 180)},
|
||||
{"White", OptionsAction::Select, ScreenColor(255, 255, 255)},
|
||||
{"Gray", OptionsAction::Select, ScreenColor(128, 128, 128)},
|
||||
};
|
||||
|
||||
constexpr size_t colorCount = sizeof(colorOptions) / sizeof(colorOptions[0]);
|
||||
static std::array<const char *, colorCount> colorLabels{};
|
||||
|
||||
auto bannerOptions = createStaticBannerOptions(
|
||||
"Select Screen Color", colorOptions, colorLabels, [display](const ScreenColorOption &option, int) -> void {
|
||||
if (option.action == OptionsAction::Back) {
|
||||
menuQueue = SystemBaseMenu;
|
||||
screen->runNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!option.hasValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
|
||||
HAS_TFT || defined(HACKADAY_COMMUNICATOR)
|
||||
const ScreenColor &color = option.value;
|
||||
if (color.useVariant) {
|
||||
LOG_INFO("Setting color to system default or defined variant");
|
||||
} else {
|
||||
LOG_INFO("Setting color to %s", option.label);
|
||||
}
|
||||
|
||||
uint8_t r = color.r;
|
||||
uint8_t g = color.g;
|
||||
uint8_t b = color.b;
|
||||
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
display->setColor(WHITE);
|
||||
|
||||
if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
|
||||
#ifdef TFT_MESH_OVERRIDE
|
||||
TFT_MESH = TFT_MESH_OVERRIDE;
|
||||
#else
|
||||
TFT_MESH = COLOR565(255, 255, 128);
|
||||
#endif
|
||||
} else {
|
||||
TFT_MESH = COLOR565(r, g, b);
|
||||
}
|
||||
|
||||
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190)
|
||||
static_cast<ST7789Spi *>(screen->getDisplayDevice())->setRGB(TFT_MESH);
|
||||
#endif
|
||||
|
||||
screen->setFrames(graphics::Screen::FOCUS_SYSTEM);
|
||||
if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
|
||||
uiconfig.screen_rgb_color = 0;
|
||||
} else {
|
||||
uiconfig.screen_rgb_color =
|
||||
(static_cast<uint32_t>(r) << 16) | (static_cast<uint32_t>(g) << 8) | static_cast<uint32_t>(b);
|
||||
}
|
||||
LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color);
|
||||
saveUIConfig();
|
||||
#endif
|
||||
});
|
||||
|
||||
int initialSelection = 0;
|
||||
if (uiconfig.screen_rgb_color == 0) {
|
||||
initialSelection = 1;
|
||||
} else {
|
||||
uint32_t currentColor = uiconfig.screen_rgb_color;
|
||||
for (size_t i = 0; i < colorCount; ++i) {
|
||||
if (!colorOptions[i].hasValue) {
|
||||
continue;
|
||||
}
|
||||
const ScreenColor &color = colorOptions[i].value;
|
||||
if (color.useVariant) {
|
||||
continue;
|
||||
}
|
||||
uint32_t encoded =
|
||||
(static_cast<uint32_t>(color.r) << 16) | (static_cast<uint32_t>(color.g) << 8) | static_cast<uint32_t>(color.b);
|
||||
if (encoded == currentColor) {
|
||||
initialSelection = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bannerOptions.InitialSelected = initialSelection;
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
void menuHandler::rebootMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Confirm"};
|
||||
@@ -2182,9 +2152,9 @@ void menuHandler::removeFavoriteMenu()
|
||||
static const char *optionsArray[] = {"Back", "Yes"};
|
||||
BannerOverlayOptions bannerOptions;
|
||||
std::string message = "Unfavorite This Node?\n";
|
||||
auto node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum);
|
||||
if (node && node->has_user) {
|
||||
message += sanitizeString(node->user.long_name).substr(0, 15);
|
||||
const auto *node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum);
|
||||
if (nodeInfoLiteHasUser(node)) {
|
||||
message += sanitizeString(node->long_name).substr(0, 15);
|
||||
}
|
||||
bannerOptions.message = message.c_str();
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2317,9 +2287,9 @@ void menuHandler::screenOptionsMenu()
|
||||
bool hasSupportBrightness = false;
|
||||
#endif
|
||||
|
||||
enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles };
|
||||
static const char *optionsArray[6] = {"Back"};
|
||||
static int optionsEnumArray[6] = {Back};
|
||||
enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme };
|
||||
static const char *optionsArray[7] = {"Back"};
|
||||
static int optionsEnumArray[7] = {Back};
|
||||
int options = 1;
|
||||
|
||||
// Only show brightness for B&W displays
|
||||
@@ -2328,13 +2298,6 @@ void menuHandler::screenOptionsMenu()
|
||||
optionsEnumArray[options++] = Brightness;
|
||||
}
|
||||
|
||||
// Only show screen color for TFT displays
|
||||
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
|
||||
HAS_TFT || defined(HACKADAY_COMMUNICATOR)
|
||||
optionsArray[options] = "Screen Color";
|
||||
optionsEnumArray[options++] = ScreenColor;
|
||||
#endif
|
||||
|
||||
optionsArray[options] = "Frame Visibility";
|
||||
optionsEnumArray[options++] = FrameToggles;
|
||||
|
||||
@@ -2344,6 +2307,11 @@ void menuHandler::screenOptionsMenu()
|
||||
optionsArray[options] = "Message Bubbles";
|
||||
optionsEnumArray[options++] = MessageBubbles;
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
optionsArray[options] = "Theme";
|
||||
optionsEnumArray[options++] = Theme;
|
||||
#endif
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Display Options";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2353,9 +2321,6 @@ void menuHandler::screenOptionsMenu()
|
||||
if (selected == Brightness) {
|
||||
menuHandler::menuQueue = menuHandler::BrightnessPicker;
|
||||
screen->runNow();
|
||||
} else if (selected == ScreenColor) {
|
||||
menuHandler::menuQueue = menuHandler::TftColorMenuPicker;
|
||||
screen->runNow();
|
||||
} else if (selected == FrameToggles) {
|
||||
menuHandler::menuQueue = menuHandler::FrameToggles;
|
||||
screen->runNow();
|
||||
@@ -2365,6 +2330,9 @@ void menuHandler::screenOptionsMenu()
|
||||
} else if (selected == MessageBubbles) {
|
||||
menuHandler::menuQueue = menuHandler::MessageBubblesMenu;
|
||||
screen->runNow();
|
||||
} else if (selected == Theme) {
|
||||
menuHandler::menuQueue = menuHandler::ThemeMenu;
|
||||
screen->runNow();
|
||||
} else {
|
||||
menuQueue = SystemBaseMenu;
|
||||
screen->runNow();
|
||||
@@ -2648,6 +2616,53 @@ void menuHandler::messageBubblesMenu()
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
void menuHandler::themeMenu()
|
||||
{
|
||||
// Build menu dynamically from the theme table.
|
||||
// Only visible themes appear!
|
||||
// Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes.
|
||||
// Bump kMaxThemesInMenu if you add more themes than will fit here.
|
||||
constexpr size_t kMaxThemesInMenu = 15;
|
||||
const size_t visibleCount = getVisibleThemeCount();
|
||||
static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"};
|
||||
const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu;
|
||||
const int options = static_cast<int>(shownCount) + 1; // +1 for Back
|
||||
|
||||
for (size_t i = 0; i < shownCount; i++) {
|
||||
optionsArray[i + 1] = getVisibleThemeByIndex(i).name;
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Theme";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
|
||||
// Highlight the currently active theme (visible index + 1 for the Back
|
||||
// offset). If the active theme is hidden, leave selection on "Back".
|
||||
const size_t activeVisible = getActiveVisibleThemeIndex();
|
||||
bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast<int>(activeVisible) + 1;
|
||||
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
if (selected == 0) {
|
||||
// Back
|
||||
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
|
||||
screen->runNow();
|
||||
} else {
|
||||
// Selection is an index into the VISIBLE themes (1-based, slot 0 is Back).
|
||||
const size_t visibleIdx = static_cast<size_t>(selected - 1);
|
||||
if (visibleIdx < getVisibleThemeCount()) {
|
||||
// Persist the theme's uniqueIdentifier so boot-time
|
||||
// resolveThemeIndex() can restore this theme on next startup.
|
||||
uiconfig.screen_rgb_color = COLOR565(255, 255, (getVisibleThemeByIndex(visibleIdx).uniqueIdentifier & 0x1F) << 3);
|
||||
loadThemeDefaults();
|
||||
saveUIConfig();
|
||||
screen->runNow();
|
||||
}
|
||||
}
|
||||
};
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
{
|
||||
if (menuQueue != MenuNone)
|
||||
@@ -2723,9 +2738,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case MuiPicker:
|
||||
switchToMUIMenu();
|
||||
break;
|
||||
case TftColorMenuPicker:
|
||||
TFTColorPickerMenu(display);
|
||||
break;
|
||||
case BrightnessPicker:
|
||||
BrightnessPickerMenu();
|
||||
break;
|
||||
@@ -2798,6 +2810,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case MessageBubblesMenu:
|
||||
messageBubblesMenu();
|
||||
break;
|
||||
case ThemeMenu:
|
||||
themeMenu();
|
||||
break;
|
||||
}
|
||||
menuQueue = MenuNone;
|
||||
}
|
||||
@@ -2809,4 +2824,4 @@ void menuHandler::saveUIConfig()
|
||||
|
||||
} // namespace graphics
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -30,7 +30,6 @@ class menuHandler
|
||||
ResetNodeDbMenu,
|
||||
BuzzerModeMenuPicker,
|
||||
MuiPicker,
|
||||
TftColorMenuPicker,
|
||||
BrightnessPicker,
|
||||
RebootMenu,
|
||||
ShutdownMenu,
|
||||
@@ -55,7 +54,8 @@ class menuHandler
|
||||
NodeNameLengthMenu,
|
||||
FrameToggles,
|
||||
DisplayUnits,
|
||||
MessageBubblesMenu
|
||||
MessageBubblesMenu,
|
||||
ThemeMenu
|
||||
};
|
||||
static screenMenus menuQueue;
|
||||
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
|
||||
@@ -89,7 +89,6 @@ class menuHandler
|
||||
static void GPSPositionBroadcastMenu();
|
||||
static void BuzzerModeMenu();
|
||||
static void switchToMUIMenu();
|
||||
static void TFTColorPickerMenu(OLEDDisplay *display);
|
||||
static void nodeListMenu();
|
||||
static void resetNodeDBMenu();
|
||||
static void BrightnessPickerMenu();
|
||||
@@ -110,6 +109,7 @@ class menuHandler
|
||||
static void frameTogglesMenu();
|
||||
static void displayUnitsMenu();
|
||||
static void messageBubblesMenu();
|
||||
static void themeMenu();
|
||||
static void textMessageMenu();
|
||||
|
||||
private:
|
||||
@@ -136,23 +136,10 @@ template <typename T> struct MenuOption {
|
||||
MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {}
|
||||
};
|
||||
|
||||
struct ScreenColor {
|
||||
uint8_t r;
|
||||
uint8_t g;
|
||||
uint8_t b;
|
||||
bool useVariant;
|
||||
|
||||
explicit ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false)
|
||||
: r(rIn), g(gIn), b(bIn), useVariant(variantIn)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
using RadioPresetOption = MenuOption<meshtastic_Config_LoRaConfig_ModemPreset>;
|
||||
using LoraRegionOption = MenuOption<meshtastic_Config_LoRaConfig_RegionCode>;
|
||||
using TimezoneOption = MenuOption<const char *>;
|
||||
using CompassOption = MenuOption<meshtastic_CompassMode>;
|
||||
using ScreenColorOption = MenuOption<ScreenColor>;
|
||||
using GPSToggleOption = MenuOption<meshtastic_Config_PositionConfig_GpsMode>;
|
||||
using GPSFormatOption = MenuOption<meshtastic_DeviceUIConfig_GpsCoordinateFormat>;
|
||||
using NodeNameOption = MenuOption<bool>;
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "graphics/Screen.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/TimeFormatters.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "main.h"
|
||||
@@ -254,6 +256,76 @@ struct MessageBlock {
|
||||
bool mine;
|
||||
};
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine)
|
||||
{
|
||||
uint16_t bubbleOnColor;
|
||||
uint16_t bubbleOffColor;
|
||||
|
||||
if (themeId == ThemeID::Blue) {
|
||||
bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White;
|
||||
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue;
|
||||
} else {
|
||||
bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg();
|
||||
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray;
|
||||
}
|
||||
|
||||
setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor);
|
||||
}
|
||||
|
||||
static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius)
|
||||
{
|
||||
if (w <= 0 || h <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (radius <= 0 || w < 3 || h < 3) {
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while
|
||||
// scrolling long message lists (which can flatten older bubble corners).
|
||||
int capRows = 0;
|
||||
if (radius >= 4 && h >= 5) {
|
||||
capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps)
|
||||
} else if (radius >= 2 && h >= 3) {
|
||||
capRows = 1; // 3 regions total
|
||||
}
|
||||
if (capRows <= 0) {
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int row = 0; row < capRows; ++row) {
|
||||
int inset = 0;
|
||||
if (radius >= 4) {
|
||||
inset = (row == 0) ? 2 : 1;
|
||||
} else if (radius >= 2) {
|
||||
inset = 1;
|
||||
}
|
||||
const int stripW = w - (inset * 2);
|
||||
if (stripW <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int topY = y + row;
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1);
|
||||
|
||||
const int bottomY = y + h - 1 - row;
|
||||
if (bottomY != topY) {
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const int middleY = y + capRows;
|
||||
const int middleH = h - (capRows * 2);
|
||||
if (middleH > 0) {
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
|
||||
{
|
||||
if (isHeaderLine) {
|
||||
@@ -390,8 +462,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
}
|
||||
case ThreadMode::DIRECT: {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
|
||||
if (node && node->has_user && node->user.short_name[0]) {
|
||||
snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name);
|
||||
if (nodeInfoLiteHasUser(node) && node->short_name[0]) {
|
||||
snprintf(titleStr, sizeof(titleStr), "@%s", node->short_name);
|
||||
} else {
|
||||
snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
|
||||
}
|
||||
@@ -513,11 +585,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
|
||||
|
||||
char senderName[64] = "";
|
||||
if (node && node->has_user) {
|
||||
if (node->user.long_name[0]) {
|
||||
strncpy(senderName, node->user.long_name, sizeof(senderName) - 1);
|
||||
} else if (node->user.short_name[0]) {
|
||||
strncpy(senderName, node->user.short_name, sizeof(senderName) - 1);
|
||||
if (nodeInfoLiteHasUser(node)) {
|
||||
if (node->long_name[0]) {
|
||||
strncpy(senderName, node->long_name, sizeof(senderName) - 1);
|
||||
} else if (node->short_name[0]) {
|
||||
strncpy(senderName, node->short_name, sizeof(senderName) - 1);
|
||||
}
|
||||
senderName[sizeof(senderName) - 1] = '\0';
|
||||
}
|
||||
@@ -527,18 +599,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
|
||||
// If this is *our own* message, override senderName to who the recipient was
|
||||
bool mine = (m.sender == nodeDB->getNodeNum());
|
||||
if (mine && node_recipient && node_recipient->has_user) {
|
||||
if (node_recipient->user.long_name[0]) {
|
||||
strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1);
|
||||
if (mine && nodeInfoLiteHasUser(node_recipient)) {
|
||||
if (node_recipient->long_name[0]) {
|
||||
strncpy(senderName, node_recipient->long_name, sizeof(senderName) - 1);
|
||||
senderName[sizeof(senderName) - 1] = '\0';
|
||||
} else if (node_recipient->user.short_name[0]) {
|
||||
strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1);
|
||||
} else if (node_recipient->short_name[0]) {
|
||||
strncpy(senderName, node_recipient->short_name, sizeof(senderName) - 1);
|
||||
senderName[sizeof(senderName) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
// If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
|
||||
if (mine && (!node_recipient || !node_recipient->has_user ||
|
||||
(!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) {
|
||||
if (mine && (!nodeInfoLiteHasUser(node_recipient) || (!node_recipient->long_name[0] && !node_recipient->short_name[0]))) {
|
||||
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
|
||||
}
|
||||
|
||||
@@ -648,6 +719,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
const int contentBottom = scrollBottom; // already excludes nav line
|
||||
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
|
||||
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const uint32_t themeId = getActiveTheme().id;
|
||||
// Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark.
|
||||
const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue);
|
||||
#endif
|
||||
|
||||
std::vector<int> lineTop;
|
||||
lineTop.resize(cachedLines.size());
|
||||
@@ -686,6 +762,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
|
||||
int bottomY = visualBottom + BUBBLE_PAD_Y;
|
||||
|
||||
// On high-res screens, keep a 1px gap under the header
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
const int minTopY = contentTop + 1;
|
||||
if (topY < minTopY) {
|
||||
// Preserve bubble height when we push it down from the header.
|
||||
const int shift = minTopY - topY;
|
||||
topY = minTopY;
|
||||
bottomY += shift;
|
||||
}
|
||||
}
|
||||
|
||||
if (bi + 1 < blocks.size()) {
|
||||
int nextHeaderIndex = (int)blocks[bi + 1].start;
|
||||
int nextTop = lineTop[nextHeaderIndex];
|
||||
@@ -735,24 +822,56 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
const int by = topY;
|
||||
const int bw = bubbleW;
|
||||
const int bh = bubbleH;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const bool drawBubbleOutline = !useDarkModeBubbleFill;
|
||||
#else
|
||||
const bool drawBubbleOutline = true;
|
||||
#endif
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (useDarkModeBubbleFill) {
|
||||
setDarkModeBubbleRoleColors(themeId, b.mine);
|
||||
registerRoundedBubbleFillRegion(bx, by, bw, bh, r);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Draw the 4 corner arcs using drawCircleQuads
|
||||
display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
|
||||
display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
|
||||
display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
|
||||
display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
|
||||
if (drawBubbleOutline) {
|
||||
// Draw the 4 corner arcs using drawCircleQuads
|
||||
display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
|
||||
display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
|
||||
display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
|
||||
display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
|
||||
|
||||
// Draw the 4 edges between corners
|
||||
display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
|
||||
display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
|
||||
display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
|
||||
display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
|
||||
// Draw the 4 edges between corners
|
||||
display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
|
||||
display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
|
||||
display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
|
||||
display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
|
||||
}
|
||||
} else if (bubbleW > 1 && bubbleH > 1) {
|
||||
// Fallback to simple rectangle for very small bubbles
|
||||
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const bool drawBubbleOutline = !useDarkModeBubbleFill;
|
||||
#else
|
||||
const bool drawBubbleOutline = true;
|
||||
#endif
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (useDarkModeBubbleFill) {
|
||||
setDarkModeBubbleRoleColors(themeId, b.mine);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH);
|
||||
}
|
||||
#endif
|
||||
if (drawBubbleOutline) {
|
||||
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end if (showBubbles)
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (useDarkModeBubbleFill) {
|
||||
// Restore theme role defaults so other screens keep their intended palette.
|
||||
loadThemeDefaults();
|
||||
}
|
||||
#endif
|
||||
|
||||
// Render visible lines
|
||||
int lineY = yOffset;
|
||||
@@ -772,7 +891,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
headerX = x + textIndent;
|
||||
}
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
true);
|
||||
|
||||
// Draw underline just under header text
|
||||
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
||||
@@ -953,12 +1072,12 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
// Banner logic
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
|
||||
char longName[64] = "?";
|
||||
if (node && node->has_user) {
|
||||
if (node->user.long_name[0]) {
|
||||
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
|
||||
if (nodeInfoLiteHasUser(node)) {
|
||||
if (node->long_name[0]) {
|
||||
strncpy(longName, node->long_name, sizeof(longName) - 1);
|
||||
longName[sizeof(longName) - 1] = '\0';
|
||||
} else if (node->user.short_name[0]) {
|
||||
strncpy(longName, node->user.short_name, sizeof(longName) - 1);
|
||||
} else if (node->short_name[0]) {
|
||||
strncpy(longName, node->short_name, sizeof(longName) - 1);
|
||||
longName[sizeof(longName) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
#include "CompassRenderer.h"
|
||||
#include "NodeDB.h"
|
||||
#include "NodeListRenderer.h"
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
#include "modules/StatusMessageModule.h"
|
||||
#endif
|
||||
#include "UIRenderer.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "gps/RTC.h" // for getTime() function
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/images.h"
|
||||
#include "meshUtils.h"
|
||||
#include <algorithm>
|
||||
@@ -92,8 +97,35 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
||||
|
||||
// 1) Choose target candidate (long vs short) only if present
|
||||
const char *raw = nullptr;
|
||||
if (node && node->has_user) {
|
||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS && !MESHTASTIC_EXCLUDE_STATUSDB
|
||||
// If long-name mode is enabled, and we have a recent status for this node,
|
||||
// prefer "(short_name) statusText" as the raw candidate. Pull straight out
|
||||
// of NodeDB's per-NodeNum cache instead of scanning a FIFO.
|
||||
std::string composedFromStatus;
|
||||
if (config.display.use_long_node_name && nodeInfoLiteHasUser(node) && nodeDB) {
|
||||
meshtastic_StatusMessage cachedStatus;
|
||||
if (nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) {
|
||||
const char *shortName = node->short_name;
|
||||
const size_t statusLen = std::strlen(cachedStatus.status);
|
||||
composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + statusLen);
|
||||
composedFromStatus += "(";
|
||||
if (shortName && *shortName) {
|
||||
composedFromStatus += shortName;
|
||||
}
|
||||
composedFromStatus += ") ";
|
||||
composedFromStatus += cachedStatus.status;
|
||||
|
||||
raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// If we didn't compose from status, use normal long/short selection
|
||||
if (!raw) {
|
||||
if (nodeInfoLiteHasUser(node)) {
|
||||
raw = config.display.use_long_node_name ? node->long_name : node->short_name;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
|
||||
@@ -177,6 +209,33 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
|
||||
}
|
||||
}
|
||||
|
||||
static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName,
|
||||
int16_t nameX, int16_t y, int nameMaxWidth)
|
||||
{
|
||||
if (!display || !node || !nodeInfoLiteIsFavorite(node) || !isTFTColoringEnabled() || !nodeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
|
||||
const int regionWidth = min(textWidth, max(0, nameMaxWidth));
|
||||
if (regionWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Node list rows can begin a couple of pixels inside header space.
|
||||
// Clamp favorite-name color region below the header to avoid black overlap there.
|
||||
const int16_t minContentY = static_cast<int16_t>(FONT_HEIGHT_SMALL + 1);
|
||||
const int16_t regionY = max(y, minContentY);
|
||||
const int16_t yClip = regionY - y;
|
||||
const int16_t regionHeight = static_cast<int16_t>(FONT_HEIGHT_SMALL - yClip);
|
||||
if (regionHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAndRegisterTFTColorRole(TFTColorRole::FavoriteNode, TFTPalette::Yellow, TFTPalette::Black, nameX, regionY, regionWidth,
|
||||
regionHeight);
|
||||
}
|
||||
|
||||
// =============================
|
||||
// Entry Renderers
|
||||
// =============================
|
||||
@@ -191,7 +250,10 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||
#endif
|
||||
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||
|
||||
char timeStr[10];
|
||||
uint32_t seconds = sinceLastSeen(node);
|
||||
@@ -211,14 +273,14 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (nodeInfoLiteIsFavorite(node)) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
} else {
|
||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||
}
|
||||
}
|
||||
if (node->is_ignored || isMuted) {
|
||||
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||
} else {
|
||||
@@ -239,28 +301,34 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
|
||||
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
|
||||
int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
|
||||
int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
|
||||
constexpr int kBarCount = 4;
|
||||
constexpr int kBarWidth = 2;
|
||||
constexpr int kBarGap = 1;
|
||||
|
||||
int barsXOffset = columnWidth - barsOffset;
|
||||
int barsRightEdge = x + barsXOffset + ((kBarCount - 1) * (kBarWidth + kBarGap)) + kBarWidth;
|
||||
|
||||
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||
#endif
|
||||
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (nodeInfoLiteIsFavorite(node)) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
} else {
|
||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||
}
|
||||
}
|
||||
if (node->is_ignored || isMuted) {
|
||||
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||
} else {
|
||||
@@ -268,28 +336,48 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
}
|
||||
}
|
||||
|
||||
// Draw signal strength bars
|
||||
int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
|
||||
int barWidth = 2;
|
||||
int barStartX = x + barsXOffset;
|
||||
int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2;
|
||||
const bool isZeroHop = node->has_hops_away && node->hops_away == 0;
|
||||
|
||||
for (int b = 0; b < 4; b++) {
|
||||
if (b < bars) {
|
||||
int height = (b * 2);
|
||||
display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height);
|
||||
// Show signal only for direct neighbors (0 hops)
|
||||
if (isZeroHop) {
|
||||
int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
|
||||
int barStartX = x + barsXOffset;
|
||||
int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2;
|
||||
|
||||
if (bars > 0) {
|
||||
uint16_t signalBarsColor = TFTPalette::Bad;
|
||||
if (bars >= 3) {
|
||||
signalBarsColor = TFTPalette::Good;
|
||||
} else if (bars == 2) {
|
||||
signalBarsColor = TFTPalette::Medium;
|
||||
}
|
||||
|
||||
// Highest bar reaches 6 px in this renderer.
|
||||
setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barStartX, barStartY - 6,
|
||||
(kBarCount * kBarWidth) + ((kBarCount - 1) * kBarGap), 6);
|
||||
}
|
||||
|
||||
for (int b = 0; b < kBarCount; b++) {
|
||||
if (b < bars) {
|
||||
int height = (b * 2);
|
||||
display->fillRect(barStartX + (b * (kBarWidth + kBarGap)), barStartY - height, kBarWidth, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw hop count
|
||||
char hopStr[6] = "";
|
||||
if (node->has_hops_away && node->hops_away > 0)
|
||||
snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away);
|
||||
// Draw hop count + hop icon
|
||||
if (node->has_hops_away && node->hops_away > 0) {
|
||||
char hopCount[6];
|
||||
snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away);
|
||||
|
||||
if (hopStr[0] != '\0') {
|
||||
int rightEdge = x + columnWidth - hopOffset;
|
||||
int textWidth = display->getStringWidth(hopStr);
|
||||
display->drawString(rightEdge - textWidth, y, hopStr);
|
||||
const int hopCountWidth = display->getStringWidth(hopCount);
|
||||
const int gap = 1;
|
||||
const int totalWidth = hopCountWidth + gap + hop_width;
|
||||
const int hopX = barsRightEdge - totalWidth;
|
||||
const int iconY = y + (FONT_HEIGHT_SMALL - hop_height) / 2;
|
||||
|
||||
display->drawString(hopX, y, hopCount);
|
||||
display->drawXbm(hopX + hopCountWidth + gap, iconY, hop_width, hop_height, hop);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,15 +392,22 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||
#endif
|
||||
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||
char distStr[10] = "";
|
||||
|
||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
||||
double lat1 = ourNode->position.latitude_i * 1e-7;
|
||||
double lon1 = ourNode->position.longitude_i * 1e-7;
|
||||
double lat2 = node->position.latitude_i * 1e-7;
|
||||
double lon2 = node->position.longitude_i * 1e-7;
|
||||
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
meshtastic_PositionLite ourPos;
|
||||
meshtastic_PositionLite theirPos;
|
||||
const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos);
|
||||
const bool haveTheirPos = nodeDB->copyNodePosition(node->num, theirPos);
|
||||
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node) && haveOurPos && haveTheirPos) {
|
||||
double lat1 = ourPos.latitude_i * 1e-7;
|
||||
double lon1 = ourPos.longitude_i * 1e-7;
|
||||
double lat2 = theirPos.latitude_i * 1e-7;
|
||||
double lon2 = theirPos.longitude_i * 1e-7;
|
||||
|
||||
double earthRadiusKm = 6371.0;
|
||||
double dLat = (lat2 - lat1) * DEG_TO_RAD;
|
||||
@@ -358,14 +453,14 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (nodeInfoLiteIsFavorite(node)) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
} else {
|
||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||
}
|
||||
}
|
||||
if (node->is_ignored || isMuted) {
|
||||
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||
} else {
|
||||
@@ -373,14 +468,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
}
|
||||
}
|
||||
|
||||
if (strlen(distStr) > 0) {
|
||||
int offset = (currentResolution == ScreenResolution::High)
|
||||
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
||||
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
||||
int rightEdge = x + columnWidth - offset;
|
||||
int textWidth = display->getStringWidth(distStr);
|
||||
display->drawString(rightEdge - textWidth, y, distStr);
|
||||
}
|
||||
const char *distanceLabel = (strlen(distStr) > 0) ? distStr : "?";
|
||||
int offset = (currentResolution == ScreenResolution::High)
|
||||
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
||||
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
||||
int rightEdge = x + columnWidth - offset;
|
||||
int textWidth = display->getStringWidth(distanceLabel);
|
||||
display->drawString(rightEdge - textWidth, y, distanceLabel);
|
||||
}
|
||||
|
||||
void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
||||
@@ -410,19 +504,22 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
|
||||
#endif
|
||||
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (nodeInfoLiteIsFavorite(node)) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
} else {
|
||||
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
||||
}
|
||||
}
|
||||
if (node->is_ignored || isMuted) {
|
||||
if (nodeInfoLiteIsIgnored(node) || isMuted) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8);
|
||||
} else {
|
||||
@@ -431,8 +528,8 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
}
|
||||
}
|
||||
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||
double userLat, double userLon)
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
||||
float myHeadingRadian, double userLat, double userLon)
|
||||
{
|
||||
if (!nodeDB->hasValidPosition(node))
|
||||
return;
|
||||
@@ -443,14 +540,17 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
int centerX = x + columnWidth - arrowXOffset;
|
||||
int centerY = y + FONT_HEIGHT_SMALL / 2;
|
||||
|
||||
double nodeLat = node->position.latitude_i * 1e-7;
|
||||
double nodeLon = node->position.longitude_i * 1e-7;
|
||||
meshtastic_PositionLite nodePos;
|
||||
if (!nodeDB->copyNodePosition(node->num, nodePos))
|
||||
return;
|
||||
double nodeLat = nodePos.latitude_i * 1e-7;
|
||||
double nodeLon = nodePos.longitude_i * 1e-7;
|
||||
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
|
||||
float bearingToNode = RAD_TO_DEG * bearing;
|
||||
float relativeBearing = fmod((bearingToNode - myHeading + 360), 360);
|
||||
float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian);
|
||||
float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing);
|
||||
// Shrink size by 2px
|
||||
int size = FONT_HEIGHT_SMALL - 5;
|
||||
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearing);
|
||||
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg);
|
||||
/*
|
||||
float angle = relativeBearing * DEG_TO_RAD;
|
||||
float halfSize = size / 2.0;
|
||||
@@ -480,12 +580,27 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
*/
|
||||
}
|
||||
|
||||
void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float, double,
|
||||
double)
|
||||
{
|
||||
if (!nodeDB->hasValidPosition(node))
|
||||
return;
|
||||
|
||||
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
||||
int arrowXOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18);
|
||||
int centerX = x + columnWidth - arrowXOffset;
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(centerX, y, "?");
|
||||
}
|
||||
|
||||
// =============================
|
||||
// Main Screen Functions
|
||||
// =============================
|
||||
|
||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon)
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon)
|
||||
{
|
||||
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
|
||||
const int rowYOffset = FONT_HEIGHT_SMALL - 3;
|
||||
@@ -538,7 +653,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
continue;
|
||||
if (n->num == nodeDB->getNodeNum())
|
||||
continue;
|
||||
if (locationScreen && !n->has_position)
|
||||
if (locationScreen && !nodeDB->hasNodePosition(n->num))
|
||||
continue;
|
||||
|
||||
drawList.push_back(n->num);
|
||||
@@ -570,7 +685,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
renderer(display, node, xPos, yPos, columnWidth);
|
||||
|
||||
if (extras)
|
||||
extras(display, node, xPos, yPos, columnWidth, heading, lat, lon);
|
||||
extras(display, node, xPos, yPos, columnWidth, headingRadian, lat, lon);
|
||||
|
||||
lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
|
||||
yOffset += rowYOffset;
|
||||
@@ -650,6 +765,9 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
|
||||
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
|
||||
display->setColor(WHITE);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
|
||||
#endif
|
||||
|
||||
// Text
|
||||
display->drawString(boxLeft + padding, boxTop + padding, buf);
|
||||
@@ -765,11 +883,16 @@ void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
#endif
|
||||
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
float heading = 0;
|
||||
bool validHeading = false;
|
||||
auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
double lat = DegD(ourNode->position.latitude_i);
|
||||
double lon = DegD(ourNode->position.longitude_i);
|
||||
float headingRadian = 0.0f;
|
||||
const auto *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
meshtastic_PositionLite ourSelfPos;
|
||||
if (!ourNode || !nodeDB->hasValidPosition(ourNode) || !nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
double lat = DegD(ourSelfPos.latitude_i);
|
||||
double lon = DegD(ourSelfPos.longitude_i);
|
||||
|
||||
#if defined(OLED_TINY)
|
||||
display->clear();
|
||||
@@ -779,21 +902,12 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
|
||||
lastSwitchTime = now;
|
||||
}
|
||||
#endif
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) {
|
||||
#if HAS_GPS
|
||||
if (screen->hasHeading()) {
|
||||
heading = screen->getHeading(); // degrees
|
||||
validHeading = true;
|
||||
} else {
|
||||
heading = screen->estimatedHeading(lat, lon);
|
||||
validHeading = !isnan(heading);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!validHeading)
|
||||
return;
|
||||
if (!CompassRenderer::getHeadingRadians(lat, lon, headingRadian)) {
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, lat, lon);
|
||||
return;
|
||||
}
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
|
||||
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon);
|
||||
}
|
||||
|
||||
/// Draw a series of fields in a column, wrapping to multiple columns if needed
|
||||
|
||||
@@ -32,7 +32,7 @@ enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATIO
|
||||
|
||||
// Main node list screen function
|
||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float headingRadian = 0, double lat = 0,
|
||||
double lon = 0);
|
||||
|
||||
// Entry renderers
|
||||
@@ -43,8 +43,8 @@ void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
||||
void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
|
||||
|
||||
// Extras renderers
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||
double userLat, double userLon);
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
||||
float myHeadingRadian, double userLat, double userLon);
|
||||
|
||||
// Screen frame functions
|
||||
void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#include "UIRenderer.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/images.h"
|
||||
#include "input/RotaryEncoderInterruptImpl1.h"
|
||||
#include "input/UpDownInterruptImpl1.h"
|
||||
@@ -64,6 +66,110 @@ uint32_t pow_of_10(uint32_t n)
|
||||
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] = {};
|
||||
|
||||
static inline graphics::NotificationRenderer::BannerFont parseFontTagPrefix(const char *&p)
|
||||
{
|
||||
// Tags must be at the start of the line:
|
||||
// [S] small, [M] medium, [L] large
|
||||
if (p && p[0] == '[' && p[2] == ']' && p[1] != '\0') {
|
||||
char t = p[1];
|
||||
if (t == 'S') {
|
||||
p += 3;
|
||||
return graphics::NotificationRenderer::BANNER_FONT_SMALL;
|
||||
}
|
||||
if (t == 'M') {
|
||||
p += 3;
|
||||
return graphics::NotificationRenderer::BANNER_FONT_MEDIUM;
|
||||
}
|
||||
if (t == 'L') {
|
||||
p += 3;
|
||||
return graphics::NotificationRenderer::BANNER_FONT_LARGE;
|
||||
}
|
||||
}
|
||||
return graphics::NotificationRenderer::BANNER_FONT_DEFAULT;
|
||||
}
|
||||
|
||||
static inline const uint8_t *fontForBannerLine(graphics::NotificationRenderer::BannerFont f)
|
||||
{
|
||||
switch (f) {
|
||||
case graphics::NotificationRenderer::BANNER_FONT_SMALL:
|
||||
return FONT_SMALL;
|
||||
case graphics::NotificationRenderer::BANNER_FONT_MEDIUM:
|
||||
return FONT_MEDIUM;
|
||||
case graphics::NotificationRenderer::BANNER_FONT_LARGE:
|
||||
return FONT_LARGE;
|
||||
case graphics::NotificationRenderer::BANNER_FONT_DEFAULT:
|
||||
default:
|
||||
return FONT_SMALL;
|
||||
}
|
||||
}
|
||||
|
||||
static inline uint8_t effectiveLineHeightForBannerLine(graphics::NotificationRenderer::BannerFont f)
|
||||
{
|
||||
uint8_t height = FONT_HEIGHT_SMALL;
|
||||
switch (f) {
|
||||
case graphics::NotificationRenderer::BANNER_FONT_MEDIUM:
|
||||
height = FONT_HEIGHT_MEDIUM;
|
||||
break;
|
||||
case graphics::NotificationRenderer::BANNER_FONT_LARGE:
|
||||
height = FONT_HEIGHT_LARGE;
|
||||
break;
|
||||
case graphics::NotificationRenderer::BANNER_FONT_SMALL:
|
||||
case graphics::NotificationRenderer::BANNER_FONT_DEFAULT:
|
||||
default:
|
||||
height = FONT_HEIGHT_SMALL;
|
||||
break;
|
||||
}
|
||||
return (height > 3) ? (height - 3) : height;
|
||||
}
|
||||
|
||||
void graphics::NotificationRenderer::parseBannerMessageWithFonts(const char *message)
|
||||
{
|
||||
alertBannerLineCount = 0;
|
||||
for (uint8_t i = 0; i < (MAX_LINES + 1); i++) {
|
||||
alertBannerLines[i][0] = '\0';
|
||||
alertBannerLineFonts[i] = BANNER_FONT_DEFAULT;
|
||||
}
|
||||
|
||||
if (!message || !message[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char *p = message;
|
||||
|
||||
while (*p && alertBannerLineCount < (MAX_LINES + 1)) {
|
||||
const char *lineStart = p;
|
||||
while (*p && *p != '\n') {
|
||||
p++;
|
||||
}
|
||||
|
||||
char tmp[64] = {0};
|
||||
size_t len = (size_t)(p - lineStart);
|
||||
if (len > (sizeof(tmp) - 1)) {
|
||||
len = sizeof(tmp) - 1;
|
||||
}
|
||||
memcpy(tmp, lineStart, len);
|
||||
tmp[len] = '\0';
|
||||
|
||||
// Tag at start
|
||||
const char *tp = tmp;
|
||||
BannerFont f = parseFontTagPrefix(tp);
|
||||
alertBannerLineFonts[alertBannerLineCount] = f;
|
||||
|
||||
// Store stripped text
|
||||
strncpy(alertBannerLines[alertBannerLineCount], tp, sizeof(alertBannerLines[0]) - 1);
|
||||
alertBannerLines[alertBannerLineCount][sizeof(alertBannerLines[0]) - 1] = '\0';
|
||||
alertBannerLineCount++;
|
||||
|
||||
if (*p == '\n') {
|
||||
p++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Used on boot when a certificate is being created
|
||||
void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
@@ -315,12 +421,12 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
|
||||
for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) {
|
||||
char tempName[48] = {0};
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1);
|
||||
if (node && node->has_user) {
|
||||
if (nodeInfoLiteHasUser(node)) {
|
||||
const char *rawName = nullptr;
|
||||
if (node->user.long_name[0]) {
|
||||
rawName = node->user.long_name;
|
||||
} else if (node->user.short_name[0]) {
|
||||
rawName = node->user.short_name;
|
||||
if (node->long_name[0]) {
|
||||
rawName = node->long_name;
|
||||
} else if (node->short_name[0]) {
|
||||
rawName = node->short_name;
|
||||
}
|
||||
if (rawName) {
|
||||
const int arrowWidth = (currentResolution == ScreenResolution::High)
|
||||
@@ -373,23 +479,37 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp
|
||||
const char *lineStarts[MAX_LINES + 1] = {0};
|
||||
uint16_t lineCount = 0;
|
||||
char lineBuffer[40] = {0};
|
||||
bool useTaggedTextBanner =
|
||||
(current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0 && alertBannerLineCount > 0);
|
||||
|
||||
// Parse lines
|
||||
char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
|
||||
lineStarts[lineCount] = alertBannerMessage;
|
||||
if (useTaggedTextBanner) {
|
||||
lineCount = std::min<uint8_t>(alertBannerLineCount, MAX_LINES);
|
||||
for (uint16_t i = 0; i < lineCount; i++) {
|
||||
lineStarts[i] = alertBannerLines[i];
|
||||
lineLengths[i] = strlen(lineStarts[i]);
|
||||
display->setFont(fontForBannerLine(alertBannerLineFonts[i]));
|
||||
lineWidths[i] = display->getStringWidth(lineStarts[i], lineLengths[i], true);
|
||||
if (lineWidths[i] > maxWidth)
|
||||
maxWidth = lineWidths[i];
|
||||
}
|
||||
} else {
|
||||
char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
|
||||
lineStarts[lineCount] = alertBannerMessage;
|
||||
|
||||
while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
|
||||
lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n');
|
||||
lineLengths[lineCount] = lineStarts[lineCount + 1] - lineStarts[lineCount];
|
||||
if (lineStarts[lineCount + 1][0] == '\n')
|
||||
lineStarts[lineCount + 1] += 1;
|
||||
lineWidths[lineCount] = display->getStringWidth(lineStarts[lineCount], lineLengths[lineCount], true);
|
||||
if (lineWidths[lineCount] > maxWidth)
|
||||
maxWidth = lineWidths[lineCount];
|
||||
lineCount++;
|
||||
while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
|
||||
lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n');
|
||||
lineLengths[lineCount] = lineStarts[lineCount + 1] - lineStarts[lineCount];
|
||||
if (lineStarts[lineCount + 1][0] == '\n')
|
||||
lineStarts[lineCount + 1] += 1;
|
||||
lineWidths[lineCount] = display->getStringWidth(lineStarts[lineCount], lineLengths[lineCount], true);
|
||||
if (lineWidths[lineCount] > maxWidth)
|
||||
maxWidth = lineWidths[lineCount];
|
||||
lineCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Measure option widths
|
||||
display->setFont(FONT_SMALL);
|
||||
for (int i = 0; i < alertBannerOptions; i++) {
|
||||
optionWidths[i] = display->getStringWidth(optionsArrayPtr[i], strlen(optionsArrayPtr[i]), true);
|
||||
if (optionWidths[i] > maxWidth)
|
||||
@@ -503,6 +623,10 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
bool needs_bell = false;
|
||||
uint16_t lineWidths[totalLines] = {0};
|
||||
uint16_t lineLengths[totalLines] = {0};
|
||||
BannerFont lineFonts[totalLines] = {};
|
||||
uint8_t lineEffectiveHeights[totalLines] = {0};
|
||||
const char *renderLines[totalLines] = {0};
|
||||
bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0);
|
||||
|
||||
if (maxWidth != 0)
|
||||
is_picker = true;
|
||||
@@ -515,11 +639,22 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
uint16_t widestLineWithBars = 0;
|
||||
|
||||
while (lines[lineCount] != nullptr) {
|
||||
auto newlinePointer = strchr(lines[lineCount], '\n');
|
||||
const char *renderText = lines[lineCount];
|
||||
BannerFont lineFont = BANNER_FONT_DEFAULT;
|
||||
if (useTaggedBannerFonts && lineCount < alertBannerLineCount) {
|
||||
renderText = alertBannerLines[lineCount];
|
||||
lineFont = alertBannerLineFonts[lineCount];
|
||||
}
|
||||
renderLines[lineCount] = renderText;
|
||||
lineFonts[lineCount] = lineFont;
|
||||
lineEffectiveHeights[lineCount] = effectiveLineHeightForBannerLine(lineFont);
|
||||
display->setFont(fontForBannerLine(lineFont));
|
||||
|
||||
auto newlinePointer = strchr(renderText, '\n');
|
||||
if (newlinePointer)
|
||||
lineLengths[lineCount] = (newlinePointer - lines[lineCount]); // Check for newlines first
|
||||
else // if the newline wasn't found, then pull string length from strlen
|
||||
lineLengths[lineCount] = strlen(lines[lineCount]);
|
||||
lineLengths[lineCount] = (newlinePointer - renderText);
|
||||
else
|
||||
lineLengths[lineCount] = strlen(renderText);
|
||||
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
char measureBuffer[64] = {0};
|
||||
@@ -531,7 +666,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
|
||||
// Consider extra width for signal bars on lines that contain "Signal:"
|
||||
uint16_t potentialWidth = lineWidths[lineCount];
|
||||
if (graphics::bannerSignalBars >= 0 && strncmp(lines[lineCount], "Signal:", 7) == 0) {
|
||||
if (graphics::bannerSignalBars >= 0 && strncmp(renderText, "Signal:", 7) == 0) {
|
||||
const int totalBars = 5;
|
||||
const int barWidth = 3;
|
||||
const int barSpacing = 2;
|
||||
@@ -567,8 +702,21 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
|
||||
uint16_t screenHeight = display->height();
|
||||
uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3;
|
||||
uint8_t visibleTotalLines = std::min<uint8_t>(lineCount, (screenHeight - vPadding * 2) / effectiveLineHeight);
|
||||
uint16_t contentHeight = visibleTotalLines * effectiveLineHeight;
|
||||
uint8_t visibleTotalLines = 0;
|
||||
uint16_t contentHeight = 0;
|
||||
const uint16_t availableHeight = (screenHeight > (vPadding * 2)) ? (screenHeight - vPadding * 2) : 0;
|
||||
for (uint8_t i = 0; i < lineCount; i++) {
|
||||
uint8_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight;
|
||||
if (contentHeight + thisLineHeight > availableHeight) {
|
||||
break;
|
||||
}
|
||||
contentHeight += thisLineHeight;
|
||||
visibleTotalLines++;
|
||||
}
|
||||
if (visibleTotalLines == 0 && lineCount > 0) {
|
||||
visibleTotalLines = 1;
|
||||
contentHeight = lineEffectiveHeights[0] ? lineEffectiveHeights[0] : effectiveLineHeight;
|
||||
}
|
||||
uint16_t boxHeight = contentHeight + vPadding * 2;
|
||||
if (visibleTotalLines == 1) {
|
||||
boxHeight += (currentResolution == ScreenResolution::High) ? 4 : 3;
|
||||
@@ -608,18 +756,24 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
|
||||
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
|
||||
display->setColor(WHITE);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
|
||||
#endif
|
||||
|
||||
// Draw Content
|
||||
int16_t lineY = boxTop + vPadding;
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
for (int i = 0; i < visibleTotalLines; i++) {
|
||||
display->setFont(fontForBannerLine(lineFonts[i]));
|
||||
int16_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight;
|
||||
int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2;
|
||||
if (needs_bell && i == 0) {
|
||||
int bellY = lineY + (FONT_HEIGHT_SMALL - 8) / 2;
|
||||
int fontHeight = thisLineHeight + 3;
|
||||
int bellY = lineY + (fontHeight - 8) / 2;
|
||||
display->drawXbm(textX - 10, bellY, 8, 8, bell_alert);
|
||||
display->drawXbm(textX + lineWidths[i] + 2, bellY, 8, 8, bell_alert);
|
||||
}
|
||||
char lineBuffer[lineLengths[i] + 1];
|
||||
strncpy(lineBuffer, lines[i], lineLengths[i]);
|
||||
strncpy(lineBuffer, renderLines[i], lineLengths[i]);
|
||||
lineBuffer[lineLengths[i]] = '\0';
|
||||
// Determine if this is a pop-up or a pick list
|
||||
if (alertBannerOptions > 0 && i == 0) {
|
||||
@@ -630,7 +784,21 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) {
|
||||
background_yOffset = -1;
|
||||
}
|
||||
display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
|
||||
const int16_t titleBarY = boxTop + 1;
|
||||
const int16_t titleBarHeight = effectiveLineHeight - background_yOffset;
|
||||
display->fillRect(boxLeft, titleBarY, boxWidth, titleBarHeight);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (alertBannerOptions > 0) {
|
||||
const uint16_t titleTextColor =
|
||||
(getActiveTheme().id == ThemeID::DefaultLight) ? TFTPalette::Black : getThemeHeaderText();
|
||||
// Keep title role away from border/corner pixels so rounded-corner masks are not remapped to the title text
|
||||
// color.
|
||||
if (boxWidth > 2 && titleBarHeight > 0) {
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuTitle, getThemeHeaderBg(), titleTextColor, boxLeft + 1,
|
||||
titleBarY, boxWidth - 2, titleBarHeight);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
display->setColor(BLACK);
|
||||
int yOffset = 3;
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
@@ -639,7 +807,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
display->drawString(textX, lineY - yOffset, lineBuffer);
|
||||
}
|
||||
display->setColor(WHITE);
|
||||
lineY += (effectiveLineHeight - 2 - background_yOffset);
|
||||
lineY += (thisLineHeight - 2 - background_yOffset);
|
||||
} else {
|
||||
// Pop-up
|
||||
// If this is the Signal line, center text + bars as one group
|
||||
@@ -650,6 +818,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
const int barSpacing = 2;
|
||||
const int barHeightStep = 2;
|
||||
const int gap = 6;
|
||||
const int maxBarHeight = totalBars * barHeightStep;
|
||||
|
||||
int textWidth = display->getStringWidth(lineBuffer, strlen(lineBuffer), true);
|
||||
int barsWidth = totalBars * barWidth + (totalBars - 1) * barSpacing + gap;
|
||||
@@ -664,6 +833,20 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
|
||||
int baseX = groupStartX + textWidth + gap;
|
||||
int baseY = lineY + effectiveLineHeight - 1;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (graphics::bannerSignalBars > 0) {
|
||||
uint16_t signalBarsColor = TFTPalette::Medium;
|
||||
if (graphics::bannerSignalBars <= 1) {
|
||||
signalBarsColor = TFTPalette::Bad;
|
||||
} else if (graphics::bannerSignalBars >= 4) {
|
||||
signalBarsColor = TFTPalette::Good;
|
||||
}
|
||||
const int activeBars = min(graphics::bannerSignalBars, totalBars);
|
||||
const int regionWidth = activeBars * barWidth + (activeBars - 1) * barSpacing;
|
||||
setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, baseX,
|
||||
baseY - maxBarHeight, regionWidth, maxBarHeight);
|
||||
}
|
||||
#endif
|
||||
for (int b = 0; b < totalBars; b++) {
|
||||
int barHeight = (b + 1) * barHeightStep;
|
||||
int x = baseX + b * (barWidth + barSpacing);
|
||||
@@ -682,7 +865,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
display->drawString(textX, lineY, lineBuffer);
|
||||
}
|
||||
}
|
||||
lineY += (effectiveLineHeight);
|
||||
lineY += thisLineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ class NotificationRenderer
|
||||
|
||||
static bool pauseBanner;
|
||||
|
||||
enum BannerFont : uint8_t { BANNER_FONT_DEFAULT = 0, BANNER_FONT_SMALL, BANNER_FONT_MEDIUM, BANNER_FONT_LARGE };
|
||||
|
||||
static char alertBannerLines[MAX_LINES + 1][64]; // parsed text per line
|
||||
static uint8_t alertBannerLineCount;
|
||||
static BannerFont alertBannerLineFonts[MAX_LINES + 1];
|
||||
static void parseBannerMessageWithFonts(const char *message);
|
||||
static void resetBanner();
|
||||
static void showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs);
|
||||
static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
|
||||
+819
-363
File diff suppressed because it is too large
Load Diff
@@ -50,10 +50,12 @@ class UIRenderer
|
||||
// Navigation bar overlay
|
||||
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
|
||||
static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
// Icon and screen drawing functions
|
||||
static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
|
||||
@@ -25,6 +25,116 @@
|
||||
|
||||
using namespace NicheGraphics::Drivers;
|
||||
|
||||
#if defined(T5_S3_EPAPER_PRO_V2)
|
||||
// FastEPD helper symbols are defined in FastEPD.inl with C++ linkage.
|
||||
extern void bbepPCA9535DigitalWrite(uint8_t pin, uint8_t value);
|
||||
extern uint8_t bbepPCA9535DigitalRead(uint8_t pin);
|
||||
extern int bbepI2CWrite(unsigned char iAddr, unsigned char *pData, int iLen);
|
||||
extern int bbepI2CReadRegister(unsigned char iAddr, unsigned char u8Register, unsigned char *pData, int iLen);
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
#if defined(T5_S3_EPAPER_PRO_V2)
|
||||
// FastEPD default V2 power callback blocks forever waiting for PWRGOOD.
|
||||
// Replace it with a timeout-safe version so boot never deadlocks.
|
||||
int safeEPDiyV7EinkPower(void *pBBEP, int bOn)
|
||||
{
|
||||
static bool warnedPgood = false;
|
||||
static bool warnedTpsPg = false;
|
||||
static bool warnedTpsWrite = false;
|
||||
|
||||
FASTEPDSTATE *pState = static_cast<FASTEPDSTATE *>(pBBEP);
|
||||
if (!pState) {
|
||||
return BBEP_ERROR_BAD_PARAMETER;
|
||||
}
|
||||
|
||||
if (bOn == pState->pwr_on) {
|
||||
return BBEP_SUCCESS;
|
||||
}
|
||||
|
||||
if (bOn) {
|
||||
bbepPCA9535DigitalWrite(8, 1); // OE on
|
||||
bbepPCA9535DigitalWrite(9, 1); // GMOD on
|
||||
bbepPCA9535DigitalWrite(13, 1); // WAKEUP on
|
||||
bbepPCA9535DigitalWrite(11, 1); // PWRUP on
|
||||
bbepPCA9535DigitalWrite(12, 1); // VCOM CTRL on
|
||||
delay(1);
|
||||
|
||||
const uint32_t pgoodStart = millis();
|
||||
bool pgoodSeen = false;
|
||||
while (!bbepPCA9535DigitalRead(14)) { // CFG_PIN_PWRGOOD
|
||||
if ((millis() - pgoodStart) > 1200) {
|
||||
if (!warnedPgood) {
|
||||
LOG_WARN("ED047TC1: PWRGOOD timeout, continuing with fallback power-on path");
|
||||
warnedPgood = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
if (bbepPCA9535DigitalRead(14)) {
|
||||
pgoodSeen = true;
|
||||
}
|
||||
|
||||
uint8_t ucTemp[4] = {0};
|
||||
ucTemp[0] = 0x01; // TPS_REG_ENABLE
|
||||
ucTemp[1] = 0x3f; // enable rails
|
||||
const int tpsEnableRc = bbepI2CWrite(0x68, ucTemp, 2);
|
||||
|
||||
const int vcom = pState->iVCOM / -10;
|
||||
ucTemp[0] = 3; // VCOM registers 3+4 (L + H)
|
||||
ucTemp[1] = static_cast<uint8_t>(vcom);
|
||||
ucTemp[2] = static_cast<uint8_t>(vcom >> 8);
|
||||
const int tpsVcomRc = bbepI2CWrite(0x68, ucTemp, 3);
|
||||
if ((tpsEnableRc == 0 || tpsVcomRc == 0) && !warnedTpsWrite) {
|
||||
LOG_WARN("ED047TC1: TPS write did not ACK, continuing with fallback");
|
||||
warnedTpsWrite = true;
|
||||
}
|
||||
|
||||
int iTimeout = 0;
|
||||
uint8_t u8Value = 0;
|
||||
while (iTimeout < 220 && ((u8Value & 0xfa) != 0xfa)) {
|
||||
bbepI2CReadRegister(0x68, 0x0F, &u8Value, 1); // TPS_REG_PG
|
||||
iTimeout++;
|
||||
delay(1);
|
||||
}
|
||||
if (iTimeout >= 220 && !warnedTpsPg) {
|
||||
if (pgoodSeen) {
|
||||
LOG_WARN("ED047TC1: TPS power-good register timeout, panel may still work");
|
||||
} else {
|
||||
LOG_WARN("ED047TC1: TPS power-good register timeout after PWRGOOD fallback");
|
||||
}
|
||||
warnedTpsPg = true;
|
||||
}
|
||||
|
||||
pState->pwr_on = 1;
|
||||
} else {
|
||||
bbepPCA9535DigitalWrite(8, 0); // OE off
|
||||
bbepPCA9535DigitalWrite(9, 0); // GMOD off
|
||||
bbepPCA9535DigitalWrite(11, 0); // PWRUP off
|
||||
bbepPCA9535DigitalWrite(12, 0); // VCOM CTRL off
|
||||
delay(1);
|
||||
bbepPCA9535DigitalWrite(13, 0); // WAKEUP off
|
||||
pState->pwr_on = 0;
|
||||
}
|
||||
|
||||
return BBEP_SUCCESS;
|
||||
}
|
||||
#endif
|
||||
|
||||
class SafeFastEPD : public FASTEPD
|
||||
{
|
||||
public:
|
||||
void installSafePowerHandler()
|
||||
{
|
||||
#if defined(T5_S3_EPAPER_PRO_V2)
|
||||
_state.pfnEinkPower = safeEPDiyV7EinkPower;
|
||||
#endif
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
|
||||
{
|
||||
// Parallel display — SPI parameters are not used
|
||||
@@ -34,24 +144,48 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_
|
||||
(void)pin_busy;
|
||||
(void)pin_rst;
|
||||
|
||||
epaper = new FASTEPD;
|
||||
SafeFastEPD *safeEpaper = new SafeFastEPD;
|
||||
epaper = safeEpaper;
|
||||
|
||||
int initRc = BBEP_ERROR_BAD_PARAMETER;
|
||||
#if defined(T5_S3_EPAPER_PRO_V1)
|
||||
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
|
||||
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
|
||||
#elif defined(T5_S3_EPAPER_PRO_V2)
|
||||
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
|
||||
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
|
||||
// Initialize all PCA9535 port-0 pins as outputs / HIGH
|
||||
for (int i = 0; i < 8; i++) {
|
||||
epaper->ioPinMode(i, OUTPUT);
|
||||
epaper->ioWrite(i, HIGH);
|
||||
}
|
||||
// On this board, the physical side key is labeled IO48; electrically it maps to PCA9535 IO12 (bit 2 on port-1).
|
||||
// FastEPD's generic V7 init drives 8..13 as outputs; force IO12 back to input
|
||||
// so variant touch-control polling can read the key reliably.
|
||||
epaper->ioPinMode(10, INPUT);
|
||||
#else
|
||||
#error "ED047TC1 driver: unsupported variant — define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2"
|
||||
#endif
|
||||
|
||||
epaper->setMode(BB_MODE_1BPP);
|
||||
epaper->clearWhite();
|
||||
epaper->fullUpdate(true); // Blocking initial clear
|
||||
if (initRc != BBEP_SUCCESS) {
|
||||
LOG_ERROR("ED047TC1 initPanel failed rc=%d", initRc);
|
||||
return;
|
||||
}
|
||||
|
||||
safeEpaper->installSafePowerHandler();
|
||||
|
||||
const int modeRc = epaper->setMode(BB_MODE_1BPP);
|
||||
if (modeRc != BBEP_SUCCESS) {
|
||||
LOG_WARN("ED047TC1 setMode failed rc=%d", modeRc);
|
||||
}
|
||||
|
||||
const int clearRc = epaper->clearWhite();
|
||||
if (clearRc != BBEP_SUCCESS) {
|
||||
LOG_WARN("ED047TC1 clearWhite failed rc=%d", clearRc);
|
||||
}
|
||||
|
||||
const int fullRc = epaper->fullUpdate(true); // Blocking initial clear
|
||||
if (fullRc != BBEP_SUCCESS) {
|
||||
LOG_WARN("ED047TC1 initial fullUpdate failed rc=%d", fullRc);
|
||||
}
|
||||
}
|
||||
|
||||
void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
|
||||
@@ -111,9 +245,8 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
|
||||
epaper->fullUpdate(CLEAR_SLOW, false);
|
||||
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
|
||||
} else {
|
||||
// FAST: true partial update — compares pCurrent vs pPrevious and only applies
|
||||
// the update waveform to rows that actually changed. Unchanged rows get a neutral
|
||||
// signal (no visible effect). partialUpdate() updates pPrevious internally.
|
||||
// FAST: true partial update - compares pCurrent vs pPrevious and only applies
|
||||
// update waveform to rows that changed. partialUpdate() updates pPrevious.
|
||||
epaper->partialUpdate(false, 0, dstTotalRows - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,8 +362,8 @@ std::string InkHUD::Applet::parseShortName(meshtastic_NodeInfoLite *node)
|
||||
assert(node);
|
||||
|
||||
// Use the true shortname if known, and doesn't contain any unprintable characters (emoji, etc.)
|
||||
if (node->has_user) {
|
||||
std::string parsed = parse(node->user.short_name);
|
||||
if (nodeInfoLiteHasUser(node)) {
|
||||
std::string parsed = parse(node->short_name);
|
||||
if (isPrintable(parsed))
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,15 @@ class Applet : public GFX
|
||||
virtual void onFreeText(char c) {}
|
||||
virtual void onFreeTextDone() {}
|
||||
virtual void onFreeTextCancel() {}
|
||||
// Absolute display-space touch point, for touch-friendly UI interactions.
|
||||
// Return true if consumed.
|
||||
virtual bool onTouchPoint(uint16_t x, uint16_t y, bool longPress)
|
||||
{
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)longPress;
|
||||
return false;
|
||||
}
|
||||
// List of inputs which can be subscribed to
|
||||
enum InputMask { // | No Joystick | With Joystick |
|
||||
BUTTON_SHORT = 1, // | Button Click | Joystick Center Click |
|
||||
|
||||
@@ -43,8 +43,8 @@ void InkHUD::MapApplet::onRender(bool full)
|
||||
|
||||
// Add white halo outline first
|
||||
constexpr int outlinePad = 1;
|
||||
int boxSize = 11;
|
||||
int radius = 2; // rounded corner radius
|
||||
int boxSize = fontSmall.lineHeight() + 2; // scale with font so digit fits
|
||||
int radius = max(2, boxSize / 6);
|
||||
|
||||
// White halo background
|
||||
fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE);
|
||||
@@ -136,24 +136,27 @@ void InkHUD::MapApplet::onRender(bool full)
|
||||
printAt(vertBarX + (bottomLabelW / 2) + 1, bottomLabelY + (bottomLabelH / 2), vertBottomLabel, CENTER, MIDDLE);
|
||||
|
||||
// Draw our node LAST with full white fill + outline
|
||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (ourNode && nodeDB->hasValidPosition(ourNode)) {
|
||||
Marker self = calculateMarker(ourNode->position.latitude_i * 1e-7, ourNode->position.longitude_i * 1e-7, false, 0);
|
||||
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
meshtastic_PositionLite ourSelfPos;
|
||||
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
|
||||
Marker self = calculateMarker(ourSelfPos.latitude_i * 1e-7, ourSelfPos.longitude_i * 1e-7, false, 0);
|
||||
|
||||
int16_t centerX = X(0.5) + (self.eastMeters * metersToPx);
|
||||
int16_t centerY = Y(0.5) - (self.northMeters * metersToPx);
|
||||
|
||||
int16_t r = fontSmall.lineHeight() / 2; // scale marker with font
|
||||
|
||||
// White fill background + halo
|
||||
fillCircle(centerX, centerY, 8, WHITE); // big white base
|
||||
drawCircle(centerX, centerY, 8, WHITE); // crisp edge
|
||||
fillCircle(centerX, centerY, r + 2, WHITE);
|
||||
drawCircle(centerX, centerY, r + 2, WHITE);
|
||||
|
||||
// Black bullseye on top
|
||||
drawCircle(centerX, centerY, 6, BLACK);
|
||||
fillCircle(centerX, centerY, 2, BLACK);
|
||||
drawCircle(centerX, centerY, r, BLACK);
|
||||
fillCircle(centerX, centerY, max(2, r / 4), BLACK);
|
||||
|
||||
// Crosshairs
|
||||
drawLine(centerX - 8, centerY, centerX + 8, centerY, BLACK);
|
||||
drawLine(centerX, centerY - 8, centerX, centerY + 8, BLACK);
|
||||
drawLine(centerX - r - 2, centerY, centerX + r + 2, centerY, BLACK);
|
||||
drawLine(centerX, centerY - r - 2, centerX, centerY + r + 2, BLACK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +169,11 @@ void InkHUD::MapApplet::onRender(bool full)
|
||||
void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
|
||||
{
|
||||
// If we have a valid position for our own node, use that as the anchor
|
||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (ourNode && nodeDB->hasValidPosition(ourNode)) {
|
||||
*lat = ourNode->position.latitude_i * 1e-7;
|
||||
*lng = ourNode->position.longitude_i * 1e-7;
|
||||
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
meshtastic_PositionLite ourSelfPos;
|
||||
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
|
||||
*lat = ourSelfPos.latitude_i * 1e-7;
|
||||
*lng = ourSelfPos.longitude_i * 1e-7;
|
||||
} else {
|
||||
// Find mean lat long coords
|
||||
// ============================
|
||||
@@ -199,9 +203,13 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
|
||||
if (!shouldDrawNode(node))
|
||||
continue;
|
||||
|
||||
meshtastic_PositionLite pos;
|
||||
if (!nodeDB->copyNodePosition(node->num, pos))
|
||||
continue;
|
||||
|
||||
// Latitude and Longitude of node, in radians
|
||||
float latRad = node->position.latitude_i * (1e-7) * DEG_TO_RAD;
|
||||
float lngRad = node->position.longitude_i * (1e-7) * DEG_TO_RAD;
|
||||
float latRad = pos.latitude_i * (1e-7) * DEG_TO_RAD;
|
||||
float lngRad = pos.longitude_i * (1e-7) * DEG_TO_RAD;
|
||||
|
||||
// Convert to cartesian points, with center of earth at 0, 0, 0
|
||||
// Exact distance from center is irrelevant, as we're only interested in the vector
|
||||
@@ -298,13 +306,17 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
|
||||
if (!shouldDrawNode(node))
|
||||
continue;
|
||||
|
||||
meshtastic_PositionLite pos;
|
||||
if (!nodeDB->copyNodePosition(node->num, pos))
|
||||
continue;
|
||||
|
||||
// Check for a new top or bottom latitude
|
||||
float latNode = node->position.latitude_i * 1e-7;
|
||||
float latNode = pos.latitude_i * 1e-7;
|
||||
northernmost = max(northernmost, latNode);
|
||||
southernmost = min(southernmost, latNode);
|
||||
|
||||
// Longitude is trickier
|
||||
float lngNode = node->position.longitude_i * 1e-7;
|
||||
float lngNode = pos.longitude_i * 1e-7;
|
||||
float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees traveled east from lngCenter to reach node
|
||||
float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees traveled west from lngCenter to reach node
|
||||
if (degEastward < degWestward)
|
||||
@@ -370,10 +382,13 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
|
||||
{
|
||||
// Find x and y position based on node's position in nodeDB
|
||||
assert(nodeDB->hasValidPosition(node));
|
||||
Marker m = calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
||||
node->position.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
||||
node->has_hops_away, // Is the hopsAway number valid
|
||||
node->hops_away // Hops away
|
||||
meshtastic_PositionLite pos;
|
||||
const bool hasPos = nodeDB->copyNodePosition(node->num, pos);
|
||||
assert(hasPos);
|
||||
Marker m = calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
||||
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
||||
node->has_hops_away, // Is the hopsAway number valid
|
||||
node->hops_away // Hops away
|
||||
);
|
||||
|
||||
// Convert to pixel coords
|
||||
@@ -382,9 +397,9 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
|
||||
|
||||
constexpr uint16_t paddingH = 2;
|
||||
constexpr uint16_t paddingW = 4;
|
||||
uint16_t paddingInnerW = 2; // Zero'd out if no text
|
||||
constexpr uint16_t markerSizeMax = 12; // Size of cross (if marker uses a cross)
|
||||
constexpr uint16_t markerSizeMin = 5;
|
||||
uint16_t paddingInnerW = 2; // Zero'd out if no text
|
||||
uint16_t markerSizeMax = fontSmall.lineHeight(); // Scale cross with font
|
||||
uint16_t markerSizeMin = max(5, fontSmall.lineHeight() / 3);
|
||||
|
||||
int16_t textX;
|
||||
int16_t textY;
|
||||
@@ -514,13 +529,16 @@ void InkHUD::MapApplet::calculateAllMarkers()
|
||||
if (node->num == nodeDB->getNodeNum())
|
||||
continue;
|
||||
|
||||
meshtastic_PositionLite pos;
|
||||
if (!nodeDB->copyNodePosition(node->num, pos))
|
||||
continue;
|
||||
|
||||
// Calculate marker and store it
|
||||
markers.push_back(
|
||||
calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
||||
node->position.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
||||
node->has_hops_away, // Is the hopsAway number valid
|
||||
node->hops_away // Hops away
|
||||
));
|
||||
markers.push_back(calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
|
||||
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
|
||||
node->has_hops_away, // Is the hopsAway number valid
|
||||
node->hops_away // Hops away
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,21 +50,23 @@ ProcessMessage InkHUD::NodeListApplet::handleReceived(const meshtastic_MeshPacke
|
||||
c.signal = getSignalStrength(mp.rx_snr, mp.rx_rssi);
|
||||
|
||||
// Assemble info: from nodeDB (needed to detect changes)
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(c.nodeNum);
|
||||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(c.nodeNum);
|
||||
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (node) {
|
||||
if (node->has_hops_away)
|
||||
c.hopsAway = node->hops_away;
|
||||
|
||||
if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) {
|
||||
// Get lat and long as float
|
||||
// Meshtastic stores these as integers internally
|
||||
float ourLat = ourNode->position.latitude_i * 1e-7;
|
||||
float ourLong = ourNode->position.longitude_i * 1e-7;
|
||||
float theirLat = node->position.latitude_i * 1e-7;
|
||||
float theirLong = node->position.longitude_i * 1e-7;
|
||||
meshtastic_PositionLite ourPos;
|
||||
meshtastic_PositionLite theirPos;
|
||||
if (nodeDB->copyNodePosition(ourNode->num, ourPos) && nodeDB->copyNodePosition(node->num, theirPos)) {
|
||||
float ourLat = ourPos.latitude_i * 1e-7;
|
||||
float ourLong = ourPos.longitude_i * 1e-7;
|
||||
float theirLat = theirPos.latitude_i * 1e-7;
|
||||
float theirLong = theirPos.longitude_i * 1e-7;
|
||||
|
||||
c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong);
|
||||
c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +181,8 @@ void InkHUD::NodeListApplet::onRender(bool full)
|
||||
// -- Longname --
|
||||
// Parse special chars in long name
|
||||
// Use node id if unknown
|
||||
if (node && node->has_user)
|
||||
longName = parse(node->user.long_name); // Found in nodeDB
|
||||
if (nodeInfoLiteHasUser(node))
|
||||
longName = parse(node->long_name); // Found in nodeDB
|
||||
else {
|
||||
// Not found in nodeDB, show a hex nodeid instead
|
||||
longName = hexifyNodeNum(nodeNum);
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
#include "./AppSwitcherApplet.h"
|
||||
|
||||
#include "graphics/niche/InkHUD/InkHUD.h"
|
||||
#include "graphics/niche/InkHUD/Tile.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
namespace
|
||||
{
|
||||
static constexpr uint16_t BODY_MARGIN_X = 8;
|
||||
static constexpr uint16_t BODY_MARGIN_Y = 6;
|
||||
static constexpr uint16_t SLOT_GAP_X = 8;
|
||||
static constexpr uint16_t SLOT_GAP_Y = 8;
|
||||
static constexpr uint8_t ICON_RADIUS = 8;
|
||||
static constexpr uint16_t FOOTER_PAD = 4;
|
||||
static constexpr uint16_t LABEL_BOTTOM_PAD = 1;
|
||||
static constexpr uint16_t LABEL_GAP_Y = 1;
|
||||
static constexpr uint16_t TITLE_H_PAD = 8;
|
||||
|
||||
static constexpr uint8_t GRID_COLS = 3;
|
||||
static constexpr uint8_t GRID_ROWS = 4;
|
||||
static constexpr uint8_t ICON_NATIVE_SIZE = 48;
|
||||
static constexpr uint8_t ICON_OUTLINE_STROKE = 1;
|
||||
|
||||
enum class IconKind : uint8_t { GENERIC, ALL_MESSAGES, DMS, CHANNEL, POSITIONS, RECENTS, HEARD, FAVORITES };
|
||||
|
||||
struct GridLayout {
|
||||
uint16_t footerH = 0;
|
||||
uint16_t bodyTop = 0;
|
||||
uint16_t bodyBottom = 0;
|
||||
uint16_t slotW = 0;
|
||||
uint16_t slotH = 0;
|
||||
uint16_t iconBox = 0;
|
||||
};
|
||||
|
||||
/*
|
||||
* Icons sourced from Material Design Icons PNG set (Apache 2.0):
|
||||
* https://github.com/material-icons/material-icons-png
|
||||
*
|
||||
* Families used: outline-2x (48x48)
|
||||
* apps, markunread, chat, forum, place, history, hearing, star_border
|
||||
*/
|
||||
static constexpr uint64_t icon_generic_apps[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
|
||||
0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
|
||||
0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
|
||||
0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
static constexpr uint64_t icon_all_messages[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL,
|
||||
0x0FC0000003F0ULL, 0x0FE0000007F0ULL, 0x0FF800001FF0ULL, 0x0FFE00007FF0ULL, 0x0FFF0000FFF0ULL, 0x0F7FC003FEF0ULL,
|
||||
0x0F1FE007F8F0ULL, 0x0F07F81FE0F0ULL, 0x0F03FE7FC0F0ULL, 0x0F00FFFF00F0ULL, 0x0F007FFE00F0ULL, 0x0F001FF800F0ULL,
|
||||
0x0F0007E000F0ULL, 0x0F0003C000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
|
||||
0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
|
||||
0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x07FFFFFFFFE0ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
static constexpr uint64_t icon_dms[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL,
|
||||
0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
|
||||
0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
|
||||
0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
|
||||
0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
|
||||
0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F7FFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFE0ULL,
|
||||
0x0FF000000000ULL, 0x0FE000000000ULL, 0x0FC000000000ULL, 0x0F8000000000ULL, 0x0F0000000000ULL, 0x0E0000000000ULL,
|
||||
0x0C0000000000ULL, 0x080000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
static constexpr uint64_t icon_channel[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL,
|
||||
0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL,
|
||||
0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL,
|
||||
0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F7FFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL,
|
||||
0x0FFFFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL, 0x0FF0000003F0ULL, 0x0FE0000003F0ULL, 0x0FC0000003F0ULL, 0x0F80000003F0ULL,
|
||||
0x0F0FFFFFFFF0ULL, 0x0E0FFFFFFFF0ULL, 0x0C0FFFFFFFF0ULL, 0x080FFFFFFFF0ULL, 0x000FFFFFFFF0ULL, 0x000FFFFFFFF0ULL,
|
||||
0x000000000FF0ULL, 0x0000000007F0ULL, 0x0000000003F0ULL, 0x0000000001F0ULL, 0x0000000000F0ULL, 0x000000000070ULL,
|
||||
0x000000000030ULL, 0x000000000010ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
static constexpr uint64_t icon_positions[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x00001FF80000ULL, 0x00007FFE0000ULL,
|
||||
0x0001FFFF8000ULL, 0x0003FFFFC000ULL, 0x0007FFFFE000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x001F8001F800ULL,
|
||||
0x001F0000F800ULL, 0x003F07E0FC00ULL, 0x003E0FF07C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL,
|
||||
0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E0FF07C00ULL, 0x003E07E07C00ULL, 0x001F0000F800ULL,
|
||||
0x001F0000F800ULL, 0x001F8001F800ULL, 0x000F8001F000ULL, 0x000FC003F000ULL, 0x0007C003E000ULL, 0x0007E007E000ULL,
|
||||
0x0003F00FC000ULL, 0x0003F00FC000ULL, 0x0001F81F8000ULL, 0x0001FC3F8000ULL, 0x0000FC3F0000ULL, 0x00007E7E0000ULL,
|
||||
0x00003FFC0000ULL, 0x00003FFC0000ULL, 0x00001FF80000ULL, 0x00000FF00000ULL, 0x00000FF00000ULL, 0x000007E00000ULL,
|
||||
0x000003C00000ULL, 0x000001800000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
static constexpr uint64_t icon_recents[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
0x00000FFF0000ULL, 0x00003FFFC000ULL, 0x0000FFFFF000ULL, 0x0001FFFFF800ULL, 0x0007FFFFFE00ULL, 0x000FF801FF00ULL,
|
||||
0x000FE0007F00ULL, 0x001FC0003F80ULL, 0x003F00000FC0ULL, 0x003F00000FC0ULL, 0x007E00E007E0ULL, 0x007C00E003E0ULL,
|
||||
0x00FC00E003F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL,
|
||||
0x3FFFC0F001F0ULL, 0x1FFF80FC01F0ULL, 0x0FFF00FF01F0ULL, 0x07FE003F81F0ULL, 0x03FC001FC1F0ULL, 0x01F80007C3F0ULL,
|
||||
0x00F0000183E0ULL, 0x0060000007E0ULL, 0x000000000FC0ULL, 0x000000000FC0ULL, 0x0001C0003F80ULL, 0x0003E0007F00ULL,
|
||||
0x0007F801FF00ULL, 0x0007FFFFFE00ULL, 0x0001FFFFF800ULL, 0x0000FFFFF000ULL, 0x00003FFFC000ULL, 0x00000FFF0000ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
static constexpr uint64_t icon_heard[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000800000000ULL, 0x001C00000000ULL, 0x003E01FF8000ULL, 0x007F07FFE000ULL,
|
||||
0x007E1FFFF800ULL, 0x00FC3FFFFC00ULL, 0x00F87FFFFE00ULL, 0x01F8FF00FF00ULL, 0x01F0FC003F00ULL, 0x01F1F8001F80ULL,
|
||||
0x03E1F0000F80ULL, 0x03E3F07E0FC0ULL, 0x03E3E0FF07C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL,
|
||||
0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E0FF0000ULL, 0x03E3F07E0000ULL, 0x03E1F0000000ULL,
|
||||
0x01F1F8000000ULL, 0x01F1F8000000ULL, 0x01F8FC000000ULL, 0x00F87E000000ULL, 0x00FC7F800000ULL, 0x007E3FC00000ULL,
|
||||
0x007F1FE00000ULL, 0x003E0FF00000ULL, 0x001C03F00000ULL, 0x000801F80000ULL, 0x000000F80000ULL, 0x000000FC0000ULL,
|
||||
0x0000007C07C0ULL, 0x0000007E07C0ULL, 0x0000003F0FC0ULL, 0x0000003FFFC0ULL, 0x0000001FFF80ULL, 0x0000000FFF00ULL,
|
||||
0x00000007FE00ULL, 0x00000003FC00ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
static constexpr uint64_t icon_favorites[48] = {
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000001800000ULL, 0x000001800000ULL,
|
||||
0x000003C00000ULL, 0x000003C00000ULL, 0x000003C00000ULL, 0x000007E00000ULL, 0x000007E00000ULL, 0x00000FF00000ULL,
|
||||
0x00000FF00000ULL, 0x00001FF80000ULL, 0x00001FF80000ULL, 0x00001E780000ULL, 0x00003E7C0000ULL, 0x003FFC3FFC00ULL,
|
||||
0x0FFFFC3FFFF0ULL, 0x0FFFF81FFFF0ULL, 0x03FFF81FFFC0ULL, 0x01F800001F80ULL, 0x00FC00003F00ULL, 0x007E00007E00ULL,
|
||||
0x003F8001FC00ULL, 0x001FC003F800ULL, 0x000FE007F000ULL, 0x0003E007C000ULL, 0x0003E007C000ULL, 0x0003C003C000ULL,
|
||||
0x0003C003C000ULL, 0x0003C3C3C000ULL, 0x0007CFF3E000ULL, 0x00079FF9E000ULL, 0x0007FFFFE000ULL, 0x0007FE7FE000ULL,
|
||||
0x000FFC3FF000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x000F8001F000ULL, 0x001E00007000ULL, 0x001800001800ULL,
|
||||
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
|
||||
};
|
||||
|
||||
using IconBitmap = const uint64_t *;
|
||||
|
||||
IconBitmap iconBitmapForKind(IconKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case IconKind::ALL_MESSAGES:
|
||||
return icon_all_messages;
|
||||
case IconKind::DMS:
|
||||
return icon_dms;
|
||||
case IconKind::CHANNEL:
|
||||
return icon_channel;
|
||||
case IconKind::POSITIONS:
|
||||
return icon_positions;
|
||||
case IconKind::RECENTS:
|
||||
return icon_recents;
|
||||
case IconKind::HEARD:
|
||||
return icon_heard;
|
||||
case IconKind::FAVORITES:
|
||||
return icon_favorites;
|
||||
case IconKind::GENERIC:
|
||||
default:
|
||||
return icon_generic_apps;
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout computeLayout(const InkHUD::Applet *applet)
|
||||
{
|
||||
GridLayout layout;
|
||||
|
||||
const uint16_t w = applet->width();
|
||||
const uint16_t h = applet->height();
|
||||
|
||||
layout.footerH = InkHUD::Applet::fontSmall.lineHeight() + (FOOTER_PAD * 2);
|
||||
layout.bodyTop = BODY_MARGIN_Y;
|
||||
layout.bodyBottom = (h > (layout.footerH + BODY_MARGIN_Y)) ? (h - layout.footerH - BODY_MARGIN_Y) : layout.bodyTop;
|
||||
|
||||
const uint16_t bodyW = (w > (BODY_MARGIN_X * 2)) ? (w - (BODY_MARGIN_X * 2)) : 1;
|
||||
const uint16_t bodyH = (layout.bodyBottom > layout.bodyTop) ? (layout.bodyBottom - layout.bodyTop) : 1;
|
||||
const uint16_t gapsX = SLOT_GAP_X * (GRID_COLS - 1);
|
||||
const uint16_t gapsY = SLOT_GAP_Y * (GRID_ROWS - 1);
|
||||
|
||||
layout.slotW = (bodyW > gapsX) ? ((bodyW - gapsX) / GRID_COLS) : 1;
|
||||
layout.slotH = (bodyH > gapsY) ? ((bodyH - gapsY) / GRID_ROWS) : 1;
|
||||
|
||||
const uint16_t maxIconW = (layout.slotW > 6) ? (layout.slotW - 6) : layout.slotW;
|
||||
const uint16_t maxIconH = (layout.slotH > (InkHUD::Applet::fontSmall.lineHeight() + LABEL_GAP_Y + LABEL_BOTTOM_PAD + 6))
|
||||
? (layout.slotH - InkHUD::Applet::fontSmall.lineHeight() - LABEL_GAP_Y - LABEL_BOTTOM_PAD - 6)
|
||||
: layout.slotH / 2;
|
||||
layout.iconBox = std::max<uint16_t>(20, std::min<uint16_t>(maxIconW, maxIconH));
|
||||
return layout;
|
||||
}
|
||||
|
||||
std::string lowercase(const char *name)
|
||||
{
|
||||
if (!name)
|
||||
return "";
|
||||
std::string out(name);
|
||||
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return (char)std::tolower(c); });
|
||||
return out;
|
||||
}
|
||||
|
||||
IconKind iconKindForAppletName(const char *name)
|
||||
{
|
||||
const std::string lower = lowercase(name);
|
||||
if (lower.find("all message") != std::string::npos || lower.find("messages") != std::string::npos)
|
||||
return IconKind::ALL_MESSAGES;
|
||||
if (lower.find("dm") != std::string::npos)
|
||||
return IconKind::DMS;
|
||||
if (lower.find("channel") != std::string::npos)
|
||||
return IconKind::CHANNEL;
|
||||
if (lower.find("position") != std::string::npos)
|
||||
return IconKind::POSITIONS;
|
||||
if (lower.find("recent") != std::string::npos)
|
||||
return IconKind::RECENTS;
|
||||
if (lower.find("heard") != std::string::npos)
|
||||
return IconKind::HEARD;
|
||||
if (lower.find("favorite") != std::string::npos)
|
||||
return IconKind::FAVORITES;
|
||||
return IconKind::GENERIC;
|
||||
}
|
||||
|
||||
void drawIconBitmapScaled(InkHUD::Applet *applet, IconBitmap bmp48, int16_t left, int16_t top, uint16_t boxSize, uint16_t color)
|
||||
{
|
||||
if (!bmp48 || boxSize == 0)
|
||||
return;
|
||||
|
||||
auto srcOn = [bmp48](int16_t sx, int16_t sy) -> bool {
|
||||
if (sx < 0 || sy < 0 || sx >= ICON_NATIVE_SIZE || sy >= ICON_NATIVE_SIZE)
|
||||
return false;
|
||||
const uint64_t rowBits = bmp48[sy];
|
||||
return (rowBits & (1ULL << (47 - sx))) != 0;
|
||||
};
|
||||
|
||||
for (uint16_t y = 0; y < boxSize; y++) {
|
||||
const uint8_t srcY = (uint8_t)((y * ICON_NATIVE_SIZE) / boxSize);
|
||||
for (uint16_t x = 0; x < boxSize; x++) {
|
||||
const uint8_t srcX = (uint8_t)((x * ICON_NATIVE_SIZE) / boxSize);
|
||||
if (!srcOn(srcX, srcY))
|
||||
continue;
|
||||
|
||||
const uint16_t w = std::min<uint16_t>(ICON_OUTLINE_STROKE, boxSize - x);
|
||||
const uint16_t h = std::min<uint16_t>(ICON_OUTLINE_STROKE, boxSize - y);
|
||||
applet->fillRect(left + x, top + y, w, h, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
InkHUD::AppSwitcherApplet::AppSwitcherApplet()
|
||||
{
|
||||
alwaysRender = true;
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::rebuildActiveAppletList()
|
||||
{
|
||||
activeAppletIndices.clear();
|
||||
|
||||
const auto &settings = inkhud->persistence->settings;
|
||||
const uint8_t tileCount = std::min<uint8_t>(settings.userTiles.count, Persistence::MAX_TILES_GLOBAL);
|
||||
const uint8_t focusedTile = (tileCount > 0) ? std::min<uint8_t>(settings.userTiles.focused, tileCount - 1) : 0;
|
||||
|
||||
// Applets displayed on other tiles should not be selectable here.
|
||||
std::vector<bool> occupiedOnOtherTiles(inkhud->userApplets.size(), false);
|
||||
for (uint8_t tile = 0; tile < tileCount; tile++) {
|
||||
if (tile == focusedTile)
|
||||
continue;
|
||||
|
||||
const uint8_t appletIndex = settings.userTiles.displayedUserApplet[tile];
|
||||
if (appletIndex < occupiedOnOtherTiles.size())
|
||||
occupiedOnOtherTiles[appletIndex] = true;
|
||||
}
|
||||
|
||||
for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
|
||||
Applet *a = inkhud->userApplets.at(i);
|
||||
if (a && a->isActive() && !occupiedOnOtherTiles[i])
|
||||
activeAppletIndices.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t InkHUD::AppSwitcherApplet::cardsPerPage() const
|
||||
{
|
||||
return GRID_COLS * GRID_ROWS;
|
||||
}
|
||||
|
||||
uint8_t InkHUD::AppSwitcherApplet::currentPage() const
|
||||
{
|
||||
const uint8_t cpp = cardsPerPage();
|
||||
if (cpp == 0)
|
||||
return 0;
|
||||
return selectedIndex / cpp;
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::stepPage(int8_t delta)
|
||||
{
|
||||
if (activeAppletIndices.empty())
|
||||
return;
|
||||
|
||||
const uint8_t cpp = cardsPerPage();
|
||||
const uint8_t pageCount = std::max<uint8_t>(1, (activeAppletIndices.size() + cpp - 1) / cpp);
|
||||
int16_t nextPage = (int16_t)currentPage() + delta;
|
||||
while (nextPage < 0)
|
||||
nextPage += pageCount;
|
||||
while (nextPage >= pageCount)
|
||||
nextPage -= pageCount;
|
||||
|
||||
selectedIndex = std::min<uint8_t>((uint8_t)(nextPage * cpp), activeAppletIndices.size() - 1);
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::clampSelection()
|
||||
{
|
||||
if (activeAppletIndices.empty()) {
|
||||
selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndex >= activeAppletIndices.size())
|
||||
selectedIndex = activeAppletIndices.size() - 1;
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::activateSelectedApplet()
|
||||
{
|
||||
if (activeAppletIndices.empty()) {
|
||||
sendToBackground();
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t appletIndex = activeAppletIndices.at(selectedIndex);
|
||||
|
||||
sendToBackground();
|
||||
inkhud->showApplet(appletIndex);
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onForeground()
|
||||
{
|
||||
rebuildActiveAppletList();
|
||||
clampSelection();
|
||||
handleInput = true;
|
||||
lockRequests = true;
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onBackground()
|
||||
{
|
||||
handleInput = false;
|
||||
lockRequests = false;
|
||||
|
||||
if (borrowedTileOwner)
|
||||
borrowedTileOwner->bringToForeground();
|
||||
|
||||
Tile *t = getTile();
|
||||
if (t)
|
||||
t->assignApplet(borrowedTileOwner);
|
||||
borrowedTileOwner = nullptr;
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::show(Tile *t)
|
||||
{
|
||||
if (!t)
|
||||
return;
|
||||
|
||||
borrowedTileOwner = t->getAssignedApplet();
|
||||
if (borrowedTileOwner)
|
||||
borrowedTileOwner->sendToBackground();
|
||||
|
||||
t->assignApplet(this);
|
||||
bringToForeground();
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onRender(bool full)
|
||||
{
|
||||
(void)full;
|
||||
|
||||
const GridLayout layout = computeLayout(this);
|
||||
const uint8_t cpp = cardsPerPage();
|
||||
const uint8_t page = currentPage();
|
||||
const uint8_t pageStart = page * cpp;
|
||||
|
||||
setFont(fontMedium);
|
||||
setTextColor(BLACK);
|
||||
|
||||
fillRect(0, 0, width(), height(), WHITE);
|
||||
drawRect(0, 0, width(), height(), BLACK);
|
||||
|
||||
if (activeAppletIndices.empty()) {
|
||||
setFont(fontSmall);
|
||||
printAt(width() / 2, height() / 2, "No Available Applets", CENTER, MIDDLE);
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint8_t i = 0; i < cpp; i++) {
|
||||
const uint8_t idx = pageStart + i;
|
||||
if (idx >= activeAppletIndices.size())
|
||||
break;
|
||||
|
||||
const uint8_t row = i / GRID_COLS;
|
||||
const uint8_t col = i % GRID_COLS;
|
||||
const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X));
|
||||
const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y));
|
||||
const bool selected = (idx == selectedIndex);
|
||||
|
||||
const uint8_t appletIndex = activeAppletIndices.at(idx);
|
||||
Applet *a = inkhud->userApplets.at(appletIndex);
|
||||
if (!a)
|
||||
continue;
|
||||
|
||||
const int16_t iconLeft = slotL + ((layout.slotW - layout.iconBox) / 2);
|
||||
const int16_t iconTop = slotT + 1;
|
||||
|
||||
// Requested style: icon in outlined rounded square only (no filled box, no outer app card).
|
||||
drawRoundRect(iconLeft, iconTop, layout.iconBox, layout.iconBox, ICON_RADIUS, BLACK);
|
||||
if (selected)
|
||||
drawRoundRect(iconLeft + 2, iconTop + 2, layout.iconBox - 4, layout.iconBox - 4, ICON_RADIUS, BLACK);
|
||||
|
||||
const IconBitmap bmp = iconBitmapForKind(iconKindForAppletName(a->name));
|
||||
drawIconBitmapScaled(this, bmp, iconLeft + 3, iconTop + 3, layout.iconBox - 6, BLACK);
|
||||
|
||||
setFont(fontSmall);
|
||||
std::string label = a->name ? a->name : "Applet";
|
||||
const uint16_t maxLabelW = layout.slotW > 4 ? (layout.slotW - 4) : layout.slotW;
|
||||
if (getTextWidth(label) > maxLabelW) {
|
||||
while (!label.empty() && getTextWidth(label + "...") > maxLabelW)
|
||||
label.pop_back();
|
||||
label = label.empty() ? "..." : label + "...";
|
||||
}
|
||||
const int16_t labelY = iconTop + layout.iconBox + LABEL_GAP_Y;
|
||||
setTextColor(BLACK);
|
||||
printAt(slotL + (layout.slotW / 2), labelY, label.c_str(), CENTER, TOP);
|
||||
|
||||
if (a->isForeground())
|
||||
fillCircle(iconLeft + layout.iconBox - 4, iconTop + 4, 2, BLACK);
|
||||
}
|
||||
|
||||
const uint8_t pageCount = std::max<uint8_t>(1, (activeAppletIndices.size() + cpp - 1) / cpp);
|
||||
if (pageCount > 1) {
|
||||
setFont(fontSmall);
|
||||
setTextColor(BLACK);
|
||||
const int16_t footerY = height() - layout.footerH + FOOTER_PAD;
|
||||
printAt(TITLE_H_PAD, footerY, "<", LEFT, TOP);
|
||||
printAt(width() - TITLE_H_PAD, footerY, ">", RIGHT, TOP);
|
||||
const std::string pageText = std::to_string(page + 1) + "/" + std::to_string(pageCount);
|
||||
printAt(width() / 2, footerY, pageText.c_str(), CENTER, TOP);
|
||||
}
|
||||
}
|
||||
|
||||
bool InkHUD::AppSwitcherApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
|
||||
{
|
||||
(void)longPress;
|
||||
|
||||
Tile *t = getTile();
|
||||
if (!t || activeAppletIndices.empty())
|
||||
return true;
|
||||
|
||||
const uint16_t tileL = t->getLeft();
|
||||
const uint16_t tileT = t->getTop();
|
||||
const uint16_t tileR = tileL + t->getWidth();
|
||||
const uint16_t tileB = tileT + t->getHeight();
|
||||
if (x < tileL || x >= tileR || y < tileT || y >= tileB)
|
||||
return false;
|
||||
|
||||
const GridLayout layout = computeLayout(this);
|
||||
const uint8_t cpp = cardsPerPage();
|
||||
const uint8_t page = currentPage();
|
||||
const uint8_t pageStart = page * cpp;
|
||||
const int16_t localX = (int16_t)x - (int16_t)tileL;
|
||||
const int16_t localY = (int16_t)y - (int16_t)tileT;
|
||||
|
||||
for (uint8_t i = 0; i < cpp; i++) {
|
||||
const uint8_t idx = pageStart + i;
|
||||
if (idx >= activeAppletIndices.size())
|
||||
break;
|
||||
|
||||
const uint8_t row = i / GRID_COLS;
|
||||
const uint8_t col = i % GRID_COLS;
|
||||
const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X));
|
||||
const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y));
|
||||
|
||||
if (localX < slotL || localX >= (slotL + (int16_t)layout.slotW))
|
||||
continue;
|
||||
if (localY < slotT || localY >= (slotT + (int16_t)layout.slotH))
|
||||
continue;
|
||||
|
||||
selectedIndex = idx;
|
||||
clampSelection();
|
||||
activateSelectedApplet();
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint8_t pageCount = std::max<uint8_t>(1, (activeAppletIndices.size() + cpp - 1) / cpp);
|
||||
if (pageCount <= 1)
|
||||
return true;
|
||||
|
||||
const int16_t footerTop = height() - layout.footerH;
|
||||
if (localY >= footerTop) {
|
||||
if (localX < (int16_t)(width() / 3))
|
||||
stepPage(-1);
|
||||
else if (localX >= (int16_t)((width() * 2) / 3))
|
||||
stepPage(1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onButtonShortPress()
|
||||
{
|
||||
if (activeAppletIndices.empty())
|
||||
return;
|
||||
|
||||
selectedIndex = (selectedIndex + 1) % activeAppletIndices.size();
|
||||
clampSelection();
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onButtonLongPress()
|
||||
{
|
||||
activateSelectedApplet();
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onExitShort()
|
||||
{
|
||||
sendToBackground();
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onNavUp()
|
||||
{
|
||||
if (activeAppletIndices.empty())
|
||||
return;
|
||||
|
||||
if (selectedIndex == 0)
|
||||
selectedIndex = activeAppletIndices.size() - 1;
|
||||
else
|
||||
selectedIndex--;
|
||||
|
||||
clampSelection();
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
void InkHUD::AppSwitcherApplet::onNavDown()
|
||||
{
|
||||
if (activeAppletIndices.empty())
|
||||
return;
|
||||
|
||||
selectedIndex = (selectedIndex + 1) % activeAppletIndices.size();
|
||||
clampSelection();
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,51 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "graphics/niche/InkHUD/SystemApplet.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace NicheGraphics::InkHUD
|
||||
{
|
||||
|
||||
class Tile;
|
||||
|
||||
class AppSwitcherApplet : public SystemApplet
|
||||
{
|
||||
public:
|
||||
AppSwitcherApplet();
|
||||
|
||||
void onForeground() override;
|
||||
void onBackground() override;
|
||||
void onRender(bool full) override;
|
||||
|
||||
void onButtonShortPress() override;
|
||||
void onButtonLongPress() override;
|
||||
void onExitShort() override;
|
||||
void onNavUp() override;
|
||||
void onNavDown() override;
|
||||
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
|
||||
|
||||
// Open the app switcher on a user tile and temporarily replace the tile's owner.
|
||||
void show(Tile *t);
|
||||
|
||||
private:
|
||||
void rebuildActiveAppletList();
|
||||
void clampSelection();
|
||||
uint8_t cardsPerPage() const;
|
||||
uint8_t currentPage() const;
|
||||
void stepPage(int8_t delta);
|
||||
void activateSelectedApplet();
|
||||
|
||||
std::vector<uint8_t> activeAppletIndices;
|
||||
uint8_t selectedIndex = 0;
|
||||
|
||||
Applet *borrowedTileOwner = nullptr;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
@@ -1,155 +1,100 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
#include "./KeyboardApplet.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
namespace
|
||||
{
|
||||
bool usePortraitKeyboardSizing()
|
||||
{
|
||||
InkHUD::InkHUD *inkhud = InkHUD::InkHUD::getInstance();
|
||||
return inkhud && inkhud->height() > inkhud->width();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
InkHUD::KeyboardApplet::KeyboardApplet()
|
||||
{
|
||||
// Calculate row widths
|
||||
for (uint8_t row = 0; row < KBD_ROWS; row++) {
|
||||
rowWidths[row] = 0;
|
||||
for (uint8_t col = 0; col < KBD_COLS; col++)
|
||||
rowWidths[row] += keyWidths[row * KBD_COLS + col];
|
||||
}
|
||||
mode = MODE_TEXT;
|
||||
lastTypingMode = MODE_TEXT;
|
||||
emotePage = 0;
|
||||
selectedKey = 0;
|
||||
prevSelectedKey = 0;
|
||||
normalizeSelection();
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onRender(bool full)
|
||||
{
|
||||
uint16_t em = fontSmall.lineHeight(); // 16 pt
|
||||
uint16_t keyH = Y(1.0) / KBD_ROWS;
|
||||
int16_t keyTopPadding = (keyH - fontSmall.lineHeight()) / 2;
|
||||
const bool showSelection = showSelectionHighlight();
|
||||
|
||||
if (full) { // Draw full keyboard
|
||||
for (uint8_t row = 0; row < KBD_ROWS; row++) {
|
||||
|
||||
// Calculate the remaining space to be used as padding
|
||||
int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
|
||||
|
||||
// Draw keys
|
||||
uint16_t xPos = 0;
|
||||
for (uint8_t col = 0; col < KBD_COLS; col++) {
|
||||
Color fgcolor = BLACK;
|
||||
uint8_t index = row * KBD_COLS + col;
|
||||
uint16_t keyX = ((xPos * em) >> 4) + ((col * keyXPadding) / (KBD_COLS - 1));
|
||||
uint16_t keyY = row * keyH;
|
||||
uint16_t keyW = (keyWidths[index] * em) >> 4;
|
||||
if (index == selectedKey) {
|
||||
fgcolor = WHITE;
|
||||
fillRect(keyX, keyY, keyW, keyH, BLACK);
|
||||
}
|
||||
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[index], fgcolor);
|
||||
xPos += keyWidths[index];
|
||||
}
|
||||
}
|
||||
} else { // Only draw the difference
|
||||
if (selectedKey != prevSelectedKey) {
|
||||
// Draw previously selected key
|
||||
uint8_t row = prevSelectedKey / KBD_COLS;
|
||||
int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
|
||||
uint16_t xPos = 0;
|
||||
for (uint8_t i = prevSelectedKey - (prevSelectedKey % KBD_COLS); i < prevSelectedKey; i++)
|
||||
xPos += keyWidths[i];
|
||||
uint16_t keyX = ((xPos * em) >> 4) + (((prevSelectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
|
||||
uint16_t keyY = row * keyH;
|
||||
uint16_t keyW = (keyWidths[prevSelectedKey] * em) >> 4;
|
||||
fillRect(keyX, keyY, keyW, keyH, WHITE);
|
||||
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[prevSelectedKey], BLACK);
|
||||
|
||||
// Draw newly selected key
|
||||
row = selectedKey / KBD_COLS;
|
||||
keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
|
||||
xPos = 0;
|
||||
for (uint8_t i = selectedKey - (selectedKey % KBD_COLS); i < selectedKey; i++)
|
||||
xPos += keyWidths[i];
|
||||
keyX = ((xPos * em) >> 4) + (((selectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
|
||||
keyY = row * keyH;
|
||||
keyW = (keyWidths[selectedKey] * em) >> 4;
|
||||
fillRect(keyX, keyY, keyW, keyH, BLACK);
|
||||
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[selectedKey], WHITE);
|
||||
}
|
||||
if (full) {
|
||||
for (uint8_t i = 0; i < KBD_KEY_COUNT; i++)
|
||||
drawKey(i, showSelection && i == selectedKey);
|
||||
} else if (showSelection && selectedKey != prevSelectedKey) {
|
||||
drawKey(prevSelectedKey, false);
|
||||
drawKey(selectedKey, true);
|
||||
}
|
||||
|
||||
prevSelectedKey = selectedKey;
|
||||
}
|
||||
|
||||
// Draw the key label corresponding to the char
|
||||
// for most keys it draws the character itself
|
||||
// for ['\b', '\n', ' ', '\x1b'] it draws special glyphs
|
||||
void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color)
|
||||
void InkHUD::KeyboardApplet::drawKey(uint8_t index, bool selected)
|
||||
{
|
||||
if (key == '\b') {
|
||||
// Draw backspace glyph: 13 x 9 px
|
||||
/**
|
||||
* [][][][][][][][][]
|
||||
* [][] []
|
||||
* [][] [] [] []
|
||||
* [][] [] [] []
|
||||
* [][] [] []
|
||||
* [][] [] [] []
|
||||
* [][] [] [] []
|
||||
* [][] []
|
||||
* [][][][][][][][][]
|
||||
*/
|
||||
const uint8_t bsBitmap[] = {0x0f, 0xf8, 0x18, 0x08, 0x32, 0x28, 0x61, 0x48, 0xc0,
|
||||
0x88, 0x61, 0x48, 0x32, 0x28, 0x18, 0x08, 0x0f, 0xf8};
|
||||
uint16_t leftPadding = (width - 13) >> 1;
|
||||
drawBitmap(left + leftPadding, top + 1, bsBitmap, 13, 9, color);
|
||||
} else if (key == '\n') {
|
||||
// Draw done glyph: 12 x 9 px
|
||||
/**
|
||||
* [][]
|
||||
* [][]
|
||||
* [][]
|
||||
* [][]
|
||||
* [][]
|
||||
* [][] [][]
|
||||
* [][] [][]
|
||||
* [][][]
|
||||
* []
|
||||
*/
|
||||
const uint8_t doneBitmap[] = {0x00, 0x30, 0x00, 0x60, 0x00, 0xc0, 0x01, 0x80, 0x03,
|
||||
0x00, 0xc6, 0x00, 0x6c, 0x00, 0x38, 0x00, 0x10, 0x00};
|
||||
uint16_t leftPadding = (width - 12) >> 1;
|
||||
drawBitmap(left + leftPadding, top + 1, doneBitmap, 12, 9, color);
|
||||
} else if (key == ' ') {
|
||||
// Draw space glyph: 13 x 9 px
|
||||
/**
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* [] []
|
||||
* [] []
|
||||
* [][][][][][][][][][][][][]
|
||||
*
|
||||
*
|
||||
*/
|
||||
const uint8_t spaceBitmap[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
|
||||
0x08, 0x80, 0x08, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00};
|
||||
uint16_t leftPadding = (width - 13) >> 1;
|
||||
drawBitmap(left + leftPadding, top + 1, spaceBitmap, 13, 9, color);
|
||||
} else if (key == '\x1b') {
|
||||
setTextColor(color);
|
||||
std::string keyText = "ESC";
|
||||
uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
|
||||
printAt(left + leftPadding, top, keyText);
|
||||
} else {
|
||||
setTextColor(color);
|
||||
if (key >= 0x61)
|
||||
key -= 32; // capitalize
|
||||
std::string keyText = std::string(1, key);
|
||||
uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
|
||||
printAt(left + leftPadding, top, keyText);
|
||||
uint16_t keyX = 0;
|
||||
uint16_t keyY = 0;
|
||||
uint16_t keyW = 0;
|
||||
uint16_t keyH = 0;
|
||||
if (!getKeyBounds(index, keyX, keyY, keyW, keyH))
|
||||
return;
|
||||
if (keyW == 0 || keyH == 0)
|
||||
return;
|
||||
|
||||
// Translate absolute tile coordinates into applet-local coordinates.
|
||||
const int16_t localX = keyX - getTile()->getLeft();
|
||||
const int16_t localY = keyY - getTile()->getTop();
|
||||
const bool enabled = isKeyEnabledAt(index);
|
||||
|
||||
// Clean background first so hidden keys never leave stale pixels when mode changes.
|
||||
fillRect(localX, localY, keyW, keyH, WHITE);
|
||||
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
fillRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, selected ? BLACK : WHITE);
|
||||
drawRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, BLACK);
|
||||
|
||||
const int16_t labelTop = localY + ((keyH - fontSmall.lineHeight()) / 2);
|
||||
drawKeyLabel(localX, labelTop, keyW, getKeyLabelAt(index), selected ? WHITE : BLACK);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color)
|
||||
{
|
||||
if (label.empty())
|
||||
return;
|
||||
|
||||
setTextColor(color);
|
||||
uint16_t textW = getTextWidth(label);
|
||||
if (textW > width) {
|
||||
// Keep labels readable in narrow keys.
|
||||
textW = getTextWidth("..");
|
||||
printAt(left + ((width - textW) >> 1), top, "..");
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t leftPadding = (width - textW) >> 1;
|
||||
printAt(left + leftPadding, top, label);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onForeground()
|
||||
{
|
||||
handleInput = true; // Intercept the button input for our applet
|
||||
|
||||
// Select the first key
|
||||
handleInput = true;
|
||||
mode = MODE_TEXT;
|
||||
lastTypingMode = MODE_TEXT;
|
||||
emotePage = 0;
|
||||
selectedKey = 0;
|
||||
prevSelectedKey = 0;
|
||||
normalizeSelection();
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onBackground()
|
||||
@@ -159,32 +104,12 @@ void InkHUD::KeyboardApplet::onBackground()
|
||||
|
||||
void InkHUD::KeyboardApplet::onButtonShortPress()
|
||||
{
|
||||
char key = keys[selectedKey];
|
||||
if (key == '\n') {
|
||||
inkhud->freeTextDone();
|
||||
inkhud->closeKeyboard();
|
||||
} else if (key == '\x1b') {
|
||||
inkhud->freeTextCancel();
|
||||
inkhud->closeKeyboard();
|
||||
} else {
|
||||
inkhud->freeText(key);
|
||||
}
|
||||
inputSelectedKey(false);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onButtonLongPress()
|
||||
{
|
||||
char key = keys[selectedKey];
|
||||
if (key == '\n') {
|
||||
inkhud->freeTextDone();
|
||||
inkhud->closeKeyboard();
|
||||
} else if (key == '\x1b') {
|
||||
inkhud->freeTextCancel();
|
||||
inkhud->closeKeyboard();
|
||||
} else {
|
||||
if (key >= 0x61)
|
||||
key -= 32; // capitalize
|
||||
inkhud->freeText(key);
|
||||
}
|
||||
inputSelectedKey(true);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onExitShort()
|
||||
@@ -201,57 +126,377 @@ void InkHUD::KeyboardApplet::onExitLong()
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavUp()
|
||||
{
|
||||
if (selectedKey < KBD_COLS) // wrap
|
||||
if (selectedKey < KBD_COLS)
|
||||
selectedKey += KBD_COLS * (KBD_ROWS - 1);
|
||||
else // move 1 row back
|
||||
else
|
||||
selectedKey -= KBD_COLS;
|
||||
|
||||
// Request rendering over the previously drawn render
|
||||
requestUpdate(EInk::UpdateTypes::FAST, false);
|
||||
// Force an update to bypass lockRequests
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh();
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavDown()
|
||||
{
|
||||
selectedKey += KBD_COLS;
|
||||
selectedKey %= (KBD_COLS * KBD_ROWS);
|
||||
|
||||
// Request rendering over the previously drawn render
|
||||
requestUpdate(EInk::UpdateTypes::FAST, false);
|
||||
// Force an update to bypass lockRequests
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
selectedKey %= KBD_KEY_COUNT;
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh();
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavLeft()
|
||||
{
|
||||
if (selectedKey % KBD_COLS == 0) // wrap
|
||||
if (selectedKey % KBD_COLS == 0)
|
||||
selectedKey += KBD_COLS - 1;
|
||||
else // move 1 column back
|
||||
else
|
||||
selectedKey--;
|
||||
|
||||
// Request rendering over the previously drawn render
|
||||
requestUpdate(EInk::UpdateTypes::FAST, false);
|
||||
// Force an update to bypass lockRequests
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh();
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavRight()
|
||||
{
|
||||
if (selectedKey % KBD_COLS == KBD_COLS - 1) // wrap
|
||||
if (selectedKey % KBD_COLS == KBD_COLS - 1)
|
||||
selectedKey -= KBD_COLS - 1;
|
||||
else // move 1 column forward
|
||||
else
|
||||
selectedKey++;
|
||||
|
||||
// Request rendering over the previously drawn render
|
||||
requestUpdate(EInk::UpdateTypes::FAST, false);
|
||||
// Force an update to bypass lockRequests
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh();
|
||||
}
|
||||
|
||||
bool InkHUD::KeyboardApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
|
||||
{
|
||||
// If touch is outside our tile, let other handlers process it.
|
||||
if (!getTile())
|
||||
return false;
|
||||
const uint16_t tileL = getTile()->getLeft();
|
||||
const uint16_t tileT = getTile()->getTop();
|
||||
const uint16_t tileR = tileL + getTile()->getWidth();
|
||||
const uint16_t tileB = tileT + getTile()->getHeight();
|
||||
if (x < tileL || x >= tileR || y < tileT || y >= tileB)
|
||||
return false;
|
||||
|
||||
const int16_t hitIndex = getKeyIndexAt(x, y);
|
||||
// Consume touches that land in keyboard whitespace/disabled cells so we don't
|
||||
// fall back to generic short-press behavior (which would type the old selection).
|
||||
if (hitIndex < 0)
|
||||
return true;
|
||||
|
||||
const uint8_t newSelected = (uint8_t)hitIndex;
|
||||
if (selectedKey != newSelected) {
|
||||
selectedKey = newSelected;
|
||||
normalizeSelection();
|
||||
if (showSelectionHighlight())
|
||||
requestFastKeyboardRefresh();
|
||||
}
|
||||
|
||||
if (!isKeyEnabledAt(selectedKey))
|
||||
return true;
|
||||
|
||||
inputSelectedKey(longPress);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool InkHUD::KeyboardApplet::getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height)
|
||||
{
|
||||
if (index >= KBD_KEY_COUNT || !getTile())
|
||||
return false;
|
||||
|
||||
const uint16_t tileW = getTile()->getWidth();
|
||||
const uint16_t tileH = getTile()->getHeight();
|
||||
const uint16_t tileL = getTile()->getLeft();
|
||||
const uint16_t tileT = getTile()->getTop();
|
||||
const uint8_t row = index / KBD_COLS;
|
||||
const uint8_t col = index % KBD_COLS;
|
||||
|
||||
const uint16_t totalGapY = KEY_GAP_Y * (KBD_ROWS + 1);
|
||||
const uint16_t keyH = (tileH > totalGapY) ? ((tileH - totalGapY) / KBD_ROWS) : (tileH / KBD_ROWS);
|
||||
top = tileT + KEY_GAP_Y + row * (keyH + KEY_GAP_Y);
|
||||
height = keyH;
|
||||
|
||||
const uint16_t totalGapX = KEY_GAP_X * (KBD_COLS + 1);
|
||||
const uint16_t rowSpace = (tileW > totalGapX) ? (tileW - totalGapX) : tileW;
|
||||
uint32_t rowUnits = 0;
|
||||
const uint8_t rowStart = row * KBD_COLS;
|
||||
for (uint8_t i = 0; i < KBD_COLS; i++) {
|
||||
rowUnits += getKeyWidthAt(rowStart + i);
|
||||
}
|
||||
if (rowUnits == 0)
|
||||
return false;
|
||||
|
||||
uint32_t cursorX = tileL + KEY_GAP_X;
|
||||
for (uint8_t i = 0; i < col; i++) {
|
||||
const uint8_t rowIndex = rowStart + i;
|
||||
const uint32_t keyW = ((uint32_t)rowSpace * getKeyWidthAt(rowIndex)) / rowUnits;
|
||||
cursorX += keyW + KEY_GAP_X;
|
||||
}
|
||||
|
||||
left = (uint16_t)cursorX;
|
||||
|
||||
if (col == (KBD_COLS - 1)) {
|
||||
const uint32_t rightEdge = tileL + tileW - KEY_GAP_X;
|
||||
width = (rightEdge > cursorX) ? (uint16_t)(rightEdge - cursorX) : 0;
|
||||
} else {
|
||||
width = (uint16_t)(((uint32_t)rowSpace * getKeyWidthAt(index)) / rowUnits);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int16_t InkHUD::KeyboardApplet::getKeyIndexAt(uint16_t x, uint16_t y)
|
||||
{
|
||||
for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) {
|
||||
uint16_t keyL = 0;
|
||||
uint16_t keyT = 0;
|
||||
uint16_t keyW = 0;
|
||||
uint16_t keyH = 0;
|
||||
if (!getKeyBounds(i, keyL, keyT, keyW, keyH))
|
||||
return -1;
|
||||
|
||||
if (keyW == 0 || keyH == 0)
|
||||
continue;
|
||||
|
||||
if (x >= keyL && x < (keyL + keyW) && y >= keyT && y < (keyT + keyH))
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::inputSelectedKey(bool longPress)
|
||||
{
|
||||
inputKeyCode(getKeyCodeAt(selectedKey), longPress);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::inputKeyCode(int16_t keyCode, bool longPress)
|
||||
{
|
||||
if (keyCode == KEY_NONE)
|
||||
return;
|
||||
|
||||
if (keyCode >= KEY_EMOTE_SLOT_BASE) {
|
||||
const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE);
|
||||
const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot;
|
||||
if (emoteIndex < fontEmoteCount)
|
||||
inkhud->freeText((char)fontEmotes[emoteIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY_BACKSPACE:
|
||||
inkhud->freeText('\b');
|
||||
return;
|
||||
case KEY_SEND:
|
||||
inkhud->freeTextDone();
|
||||
inkhud->closeKeyboard();
|
||||
return;
|
||||
case KEY_EMOTE_TOGGLE:
|
||||
toggleEmoteMode();
|
||||
return;
|
||||
case KEY_PUNCT_TOGGLE:
|
||||
case KEY_ALPHA_TOGGLE:
|
||||
togglePunctuationMode();
|
||||
return;
|
||||
case KEY_EMOTE_UP:
|
||||
pageEmotes(false);
|
||||
return;
|
||||
case KEY_EMOTE_DOWN:
|
||||
pageEmotes(true);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (keyCode >= 0 && keyCode <= 0xFF) {
|
||||
char key = (char)keyCode;
|
||||
if (longPress && key >= 'a' && key <= 'z')
|
||||
key = (char)std::toupper((unsigned char)key);
|
||||
inkhud->freeText(key);
|
||||
}
|
||||
}
|
||||
|
||||
int16_t InkHUD::KeyboardApplet::getKeyCodeAt(uint8_t index) const
|
||||
{
|
||||
if (index >= KBD_KEY_COUNT)
|
||||
return KEY_NONE;
|
||||
|
||||
if (mode == MODE_TEXT)
|
||||
return textKeys[index];
|
||||
if (mode == MODE_PUNCT)
|
||||
return punctKeys[index];
|
||||
|
||||
// Emote mode
|
||||
if (index < EMOTE_SLOT_COUNT) {
|
||||
const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + index;
|
||||
if (emoteIndex < fontEmoteCount)
|
||||
return KEY_EMOTE_SLOT_BASE + index;
|
||||
return KEY_NONE;
|
||||
}
|
||||
|
||||
// Emote controls on the bottom row
|
||||
switch (index - EMOTE_SLOT_COUNT) {
|
||||
case 0:
|
||||
return KEY_EMOTE_UP;
|
||||
case 1:
|
||||
return KEY_EMOTE_DOWN;
|
||||
case 2:
|
||||
return KEY_ALPHA_TOGGLE;
|
||||
case 3:
|
||||
return ',';
|
||||
case 4:
|
||||
return ' ';
|
||||
case 5:
|
||||
return '.';
|
||||
case 6:
|
||||
return KEY_SEND;
|
||||
case 7:
|
||||
return KEY_BACKSPACE;
|
||||
default:
|
||||
return KEY_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t InkHUD::KeyboardApplet::getKeyWidthAt(uint8_t index) const
|
||||
{
|
||||
if (index >= KBD_KEY_COUNT)
|
||||
return 0;
|
||||
|
||||
if (mode == MODE_EMOTE)
|
||||
return emoteKeyWidths[index];
|
||||
return typingKeyWidths[index];
|
||||
}
|
||||
|
||||
std::string InkHUD::KeyboardApplet::getKeyLabelAt(uint8_t index) const
|
||||
{
|
||||
const int16_t keyCode = getKeyCodeAt(index);
|
||||
if (keyCode == KEY_NONE)
|
||||
return "";
|
||||
|
||||
if (keyCode >= KEY_EMOTE_SLOT_BASE) {
|
||||
const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE);
|
||||
const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot;
|
||||
if (emoteIndex < fontEmoteCount)
|
||||
return std::string(1, (char)fontEmotes[emoteIndex]);
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY_BACKSPACE:
|
||||
return "DEL";
|
||||
case KEY_SEND:
|
||||
return "SEND";
|
||||
case KEY_EMOTE_TOGGLE:
|
||||
return std::string(1, (char)0x03); // Smiling face icon from InkHUD emote font map
|
||||
case KEY_PUNCT_TOGGLE:
|
||||
return "!#1";
|
||||
case KEY_ALPHA_TOGGLE:
|
||||
return "ABC";
|
||||
case KEY_EMOTE_UP:
|
||||
return "UP";
|
||||
case KEY_EMOTE_DOWN:
|
||||
return "DN";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (keyCode >= 0 && keyCode <= 0xFF) {
|
||||
const char c = (char)keyCode;
|
||||
if (c == ' ')
|
||||
return "SPACE";
|
||||
if (c >= 'a' && c <= 'z')
|
||||
return std::string(1, (char)std::toupper((unsigned char)c));
|
||||
return std::string(1, c);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
bool InkHUD::KeyboardApplet::isKeyEnabledAt(uint8_t index) const
|
||||
{
|
||||
return getKeyCodeAt(index) != KEY_NONE;
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::normalizeSelection()
|
||||
{
|
||||
if (selectedKey >= KBD_KEY_COUNT)
|
||||
selectedKey = 0;
|
||||
|
||||
if (isKeyEnabledAt(selectedKey))
|
||||
return;
|
||||
|
||||
for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) {
|
||||
if (isKeyEnabledAt(i)) {
|
||||
selectedKey = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::togglePunctuationMode()
|
||||
{
|
||||
if (mode == MODE_EMOTE) {
|
||||
mode = lastTypingMode;
|
||||
} else {
|
||||
mode = (mode == MODE_TEXT) ? MODE_PUNCT : MODE_TEXT;
|
||||
lastTypingMode = mode;
|
||||
}
|
||||
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh(true);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::toggleEmoteMode()
|
||||
{
|
||||
if (mode == MODE_EMOTE) {
|
||||
mode = lastTypingMode;
|
||||
} else {
|
||||
lastTypingMode = mode;
|
||||
mode = MODE_EMOTE;
|
||||
}
|
||||
|
||||
emotePage = 0;
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh(true);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::pageEmotes(bool down)
|
||||
{
|
||||
if (mode != MODE_EMOTE)
|
||||
return;
|
||||
|
||||
const uint8_t maxPage = (fontEmoteCount == 0) ? 0 : (uint8_t)((fontEmoteCount - 1) / EMOTE_SLOT_COUNT);
|
||||
|
||||
if (down) {
|
||||
if (emotePage < maxPage)
|
||||
emotePage++;
|
||||
} else {
|
||||
if (emotePage > 0)
|
||||
emotePage--;
|
||||
}
|
||||
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh(true);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::requestFastKeyboardRefresh(bool full)
|
||||
{
|
||||
requestUpdate(EInk::UpdateTypes::FAST, full);
|
||||
}
|
||||
|
||||
bool InkHUD::KeyboardApplet::showSelectionHighlight() const
|
||||
{
|
||||
// On touch-capable devices, prioritize input throughput over per-key highlight updates.
|
||||
// E-ink refresh can lag rapid taps; skipping highlight avoids update-induced input latency.
|
||||
return !inkhud->hasTouchEnabledProvider();
|
||||
}
|
||||
|
||||
uint16_t InkHUD::KeyboardApplet::getKeyboardHeight()
|
||||
{
|
||||
const uint16_t keyH = fontSmall.lineHeight() * 1.2;
|
||||
return keyH * KBD_ROWS;
|
||||
// Keep touch keys tall and roomy for finger input.
|
||||
// In portrait orientation we increase row height for larger touch targets.
|
||||
const uint16_t rowUnit = fontSmall.lineHeight() + 8;
|
||||
const uint8_t rowScale = usePortraitKeyboardSizing() ? 3 : 2;
|
||||
const uint16_t keyH = rowUnit * rowScale;
|
||||
return (keyH * KBD_ROWS) + (KEY_GAP_Y * (KBD_ROWS + 1));
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,7 @@ System Applet to render an on-screen keyboard
|
||||
#include "graphics/niche/InkHUD/InkHUD.h"
|
||||
#include "graphics/niche/InkHUD/SystemApplet.h"
|
||||
#include <string>
|
||||
|
||||
namespace NicheGraphics::InkHUD
|
||||
{
|
||||
|
||||
@@ -31,34 +32,111 @@ class KeyboardApplet : public SystemApplet
|
||||
void onNavDown() override;
|
||||
void onNavLeft() override;
|
||||
void onNavRight() override;
|
||||
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
|
||||
|
||||
static uint16_t getKeyboardHeight(); // used to set the keyboard tile height
|
||||
|
||||
private:
|
||||
void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color);
|
||||
enum KeyCode : int16_t {
|
||||
KEY_NONE = -1,
|
||||
KEY_BACKSPACE = 256,
|
||||
KEY_SEND,
|
||||
KEY_EMOTE_TOGGLE,
|
||||
KEY_PUNCT_TOGGLE,
|
||||
KEY_ALPHA_TOGGLE,
|
||||
KEY_EMOTE_UP,
|
||||
KEY_EMOTE_DOWN,
|
||||
KEY_EMOTE_SLOT_BASE = 512
|
||||
};
|
||||
|
||||
enum KeyboardMode : uint8_t { MODE_TEXT = 0, MODE_PUNCT = 1, MODE_EMOTE = 2 };
|
||||
|
||||
void drawKey(uint8_t index, bool selected);
|
||||
void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color);
|
||||
bool getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height);
|
||||
int16_t getKeyIndexAt(uint16_t x, uint16_t y);
|
||||
void inputSelectedKey(bool longPress);
|
||||
void inputKeyCode(int16_t keyCode, bool longPress);
|
||||
int16_t getKeyCodeAt(uint8_t index) const;
|
||||
uint16_t getKeyWidthAt(uint8_t index) const;
|
||||
std::string getKeyLabelAt(uint8_t index) const;
|
||||
bool isKeyEnabledAt(uint8_t index) const;
|
||||
void normalizeSelection();
|
||||
void togglePunctuationMode();
|
||||
void toggleEmoteMode();
|
||||
void pageEmotes(bool down);
|
||||
void requestFastKeyboardRefresh(bool full = false);
|
||||
bool showSelectionHighlight() const;
|
||||
|
||||
static const uint8_t KBD_COLS = 11;
|
||||
static const uint8_t KBD_ROWS = 4;
|
||||
static const uint8_t KBD_ROWS = 5;
|
||||
static const uint8_t KBD_KEY_COUNT = KBD_COLS * KBD_ROWS;
|
||||
static const uint8_t EMOTE_SLOT_COUNT = KBD_COLS * (KBD_ROWS - 1); // top 4 rows
|
||||
static constexpr uint8_t fontEmotes[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0B, 0x0C, 0x0E, 0x0F, 0x10, 0x11,
|
||||
0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F};
|
||||
static constexpr uint8_t fontEmoteCount = sizeof(fontEmotes) / sizeof(fontEmotes[0]);
|
||||
|
||||
const char keys[KBD_COLS * KBD_ROWS] = {
|
||||
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '\b', // row 0
|
||||
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '\n', // row 1
|
||||
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '!', ' ', // row 2
|
||||
'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '?', '\x1b' // row 3
|
||||
};
|
||||
// Text keyboard (requested layout):
|
||||
// row 0: 1..0
|
||||
// row 1: q..p
|
||||
// row 2: a..l
|
||||
// row 3: EMO, z..m, DEL
|
||||
// row 4: !#1, comma, space, period, SEND
|
||||
const int16_t textKeys[KBD_KEY_COUNT] = {
|
||||
// row 0
|
||||
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE,
|
||||
// row 1
|
||||
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', KEY_NONE,
|
||||
// row 2
|
||||
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', KEY_NONE, KEY_NONE,
|
||||
// row 3
|
||||
KEY_EMOTE_TOGGLE, 'z', 'x', 'c', 'v', 'b', 'n', 'm', KEY_BACKSPACE, KEY_NONE, KEY_NONE,
|
||||
// row 4
|
||||
KEY_PUNCT_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE};
|
||||
|
||||
// This array represents the widths of each key in points
|
||||
// 16 pt = line height of the text
|
||||
const uint16_t keyWidths[KBD_COLS * KBD_ROWS] = {
|
||||
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 0
|
||||
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 1
|
||||
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 2
|
||||
16, 16, 16, 16, 16, 16, 16, 10, 10, 12, 40 // row 3
|
||||
};
|
||||
// Punctuation keyboard (toggle via !#1/ABC)
|
||||
const int16_t punctKeys[KBD_KEY_COUNT] = {
|
||||
// row 0
|
||||
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE,
|
||||
// row 1
|
||||
'!', '@', '#', '$', '%', '^', '&', '*', '(', ')', KEY_NONE,
|
||||
// row 2
|
||||
'-', '_', '=', '+', '[', ']', '{', '}', '/', '?', KEY_NONE,
|
||||
// row 3
|
||||
KEY_EMOTE_TOGGLE, ';', ':', '\'', '"', '<', '>', '\\', KEY_BACKSPACE, KEY_NONE, KEY_NONE,
|
||||
// row 4
|
||||
KEY_ALPHA_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE};
|
||||
|
||||
uint16_t rowWidths[KBD_ROWS];
|
||||
uint8_t selectedKey = 0; // selected key index
|
||||
const uint16_t typingKeyWidths[KBD_KEY_COUNT] = {// row 0
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0,
|
||||
// row 1
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0,
|
||||
// row 2
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 0, 0,
|
||||
// row 3
|
||||
18, 12, 12, 12, 12, 12, 12, 12, 20, 0, 0,
|
||||
// row 4
|
||||
20, 12, 56, 12, 24, 0, 0, 0, 0, 0, 0};
|
||||
|
||||
const uint16_t emoteKeyWidths[KBD_KEY_COUNT] = {// row 0
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
|
||||
// row 1
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
|
||||
// row 2
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
|
||||
// row 3
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
|
||||
// row 4 controls
|
||||
14, 14, 18, 12, 40, 12, 20, 18, 0, 0, 0};
|
||||
|
||||
uint8_t selectedKey = 0;
|
||||
uint8_t prevSelectedKey = 0;
|
||||
uint8_t emotePage = 0;
|
||||
KeyboardMode mode = MODE_TEXT;
|
||||
KeyboardMode lastTypingMode = MODE_TEXT;
|
||||
static constexpr uint8_t KEY_GAP_X = 3;
|
||||
static constexpr uint8_t KEY_GAP_Y = 4;
|
||||
static constexpr uint8_t KEY_RADIUS = 4;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
@@ -64,6 +64,8 @@ enum MenuAction {
|
||||
SET_REGION_KZ_863,
|
||||
SET_REGION_NP_865,
|
||||
SET_REGION_BR_902,
|
||||
SET_REGION_EU_866,
|
||||
SET_REGION_NARROW_868,
|
||||
// Device Roles
|
||||
SET_ROLE_CLIENT,
|
||||
SET_ROLE_CLIENT_MUTE,
|
||||
@@ -78,6 +80,11 @@ enum MenuAction {
|
||||
SET_PRESET_SHORT_SLOW,
|
||||
SET_PRESET_SHORT_FAST,
|
||||
SET_PRESET_SHORT_TURBO,
|
||||
SET_PRESET_LITE_SLOW,
|
||||
SET_PRESET_LITE_FAST,
|
||||
SET_PRESET_NARROW_SLOW,
|
||||
SET_PRESET_NARROW_FAST,
|
||||
SET_PRESET_FROM_REGION, // Dynamic: preset chosen from region-available list
|
||||
// Timezones
|
||||
SET_TZ_US_HAWAII,
|
||||
SET_TZ_US_ALASKA,
|
||||
@@ -109,6 +116,7 @@ enum MenuAction {
|
||||
TOGGLE_CHANNEL_POSITION,
|
||||
SET_CHANNEL_PRECISION,
|
||||
// Display
|
||||
SET_DISPLAY_TIMEOUT,
|
||||
TOGGLE_DISPLAY_UNITS,
|
||||
// Network
|
||||
TOGGLE_WIFI,
|
||||
@@ -119,4 +127,4 @@ enum MenuAction {
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
#include "DisplayFormatters.h"
|
||||
#include "GPS.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "RTC.h"
|
||||
#include "Router.h"
|
||||
#include "airtime.h"
|
||||
#include "graphics/niche/Utils/FlashData.h"
|
||||
#include "main.h"
|
||||
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
||||
#include "power.h"
|
||||
@@ -27,6 +29,16 @@ static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu
|
||||
// These are offered to users as possible values for settings.recentlyActiveSeconds
|
||||
static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120};
|
||||
|
||||
struct DisplayTimeoutOption {
|
||||
uint32_t seconds;
|
||||
const char *label;
|
||||
};
|
||||
|
||||
static constexpr DisplayTimeoutOption DISPLAY_TIMEOUT_OPTIONS[] = {
|
||||
{0, "Forever"}, {30, "30 secs"}, {60, "1 min"}, {5 * 60, "5 min"},
|
||||
{15 * 60, "15 min"}, {30 * 60, "30 min"}, {60 * 60, "1 hr"},
|
||||
};
|
||||
|
||||
struct PositionPrecisionOption {
|
||||
uint8_t value; // proto value
|
||||
const char *metric;
|
||||
@@ -39,6 +51,77 @@ static constexpr PositionPrecisionOption POSITION_PRECISION_OPTIONS[] = {
|
||||
{12, "5.8 km", "3.6 mi"}, {11, "12 km", "7.3 mi"}, {10, "23 km", "15 mi"},
|
||||
};
|
||||
|
||||
static const char *getDisplayTimeoutLabel(uint32_t timeoutSeconds)
|
||||
{
|
||||
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
|
||||
for (uint8_t i = 0; i < optionCount; i++) {
|
||||
if (DISPLAY_TIMEOUT_OPTIONS[i].seconds == timeoutSeconds) {
|
||||
return DISPLAY_TIMEOUT_OPTIONS[i].label;
|
||||
}
|
||||
}
|
||||
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
static bool supportsFreeTextKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
|
||||
{
|
||||
return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
|
||||
}
|
||||
|
||||
static bool useTouchFriendlyMenuLayout(const InkHUD::InkHUD *inkhud)
|
||||
{
|
||||
return inkhud != nullptr && inkhud->hasTouchEnabledProvider();
|
||||
}
|
||||
|
||||
static uint16_t getMenuItemHeightPx(const InkHUD::InkHUD *inkhud)
|
||||
{
|
||||
const bool touchFriendly = useTouchFriendlyMenuLayout(inkhud);
|
||||
const uint16_t lineH = touchFriendly ? InkHUD::Applet::fontMedium.lineHeight() : InkHUD::Applet::fontSmall.lineHeight();
|
||||
const float rowScale = touchFriendly ? 1.9f : 1.6f;
|
||||
uint16_t itemH = (uint16_t)(lineH * rowScale);
|
||||
if (itemH == 0) {
|
||||
itemH = 1;
|
||||
}
|
||||
return itemH;
|
||||
}
|
||||
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
namespace
|
||||
{
|
||||
static constexpr uint32_t T5_BACKLIGHT_PREFS_VERSION = 1;
|
||||
|
||||
struct T5BacklightPrefs {
|
||||
uint32_t version = T5_BACKLIGHT_PREFS_VERSION;
|
||||
bool keepOn = true;
|
||||
};
|
||||
|
||||
T5BacklightPrefs t5BacklightPrefs;
|
||||
bool t5BacklightPrefsLoaded = false;
|
||||
|
||||
bool loadT5BacklightKeepOn()
|
||||
{
|
||||
if (!t5BacklightPrefsLoaded) {
|
||||
T5BacklightPrefs loaded;
|
||||
const bool ok = FlashData<T5BacklightPrefs>::load(&loaded, "t5_backlight");
|
||||
if (ok && loaded.version == T5_BACKLIGHT_PREFS_VERSION) {
|
||||
t5BacklightPrefs = loaded;
|
||||
}
|
||||
t5BacklightPrefsLoaded = true;
|
||||
}
|
||||
|
||||
return t5BacklightPrefs.keepOn;
|
||||
}
|
||||
|
||||
void saveT5BacklightKeepOn(bool keepOn)
|
||||
{
|
||||
loadT5BacklightKeepOn();
|
||||
t5BacklightPrefs.version = T5_BACKLIGHT_PREFS_VERSION;
|
||||
t5BacklightPrefs.keepOn = keepOn;
|
||||
FlashData<T5BacklightPrefs>::save(&t5BacklightPrefs, "t5_backlight");
|
||||
}
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
|
||||
{
|
||||
// No timer tasks at boot
|
||||
@@ -47,7 +130,11 @@ InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
|
||||
// Note: don't get instance if we're not actually using the backlight,
|
||||
// or else you will unintentionally instantiate it
|
||||
if (settings->optionalMenuItems.backlight) {
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
t5BacklightSetUserEnabled(loadT5BacklightKeepOn());
|
||||
#else
|
||||
backlight = Drivers::LatchingBacklight::getInstance();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Initialize the Canned Message store
|
||||
@@ -76,9 +163,11 @@ void InkHUD::MenuApplet::onForeground()
|
||||
// backlight on always when menu opens.
|
||||
// Courtesy to T-Echo users who removed the capacitive touch button
|
||||
if (settings->optionalMenuItems.backlight) {
|
||||
#if !defined(T5_S3_EPAPER_PRO)
|
||||
assert(backlight);
|
||||
if (!backlight->isOn())
|
||||
backlight->peek();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Prevent user applets requesting update while menu is open
|
||||
@@ -106,9 +195,11 @@ void InkHUD::MenuApplet::onBackground()
|
||||
// Item in options submenu allows keeping backlight on after menu is closed
|
||||
// If this item is deselected we will turn backlight off again, now that menu is closing
|
||||
if (settings->optionalMenuItems.backlight) {
|
||||
#if !defined(T5_S3_EPAPER_PRO)
|
||||
assert(backlight);
|
||||
if (!backlight->isLatched())
|
||||
backlight->off();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stop the auto-timeout
|
||||
@@ -167,6 +258,11 @@ int32_t InkHUD::MenuApplet::runOnce()
|
||||
return OSThread::disable();
|
||||
}
|
||||
|
||||
// Storage for the dynamically-built region preset list — populated in showPage(NODE_CONFIG_PRESET)
|
||||
static constexpr uint8_t MAX_REGION_PRESETS = 16;
|
||||
static meshtastic_Config_LoRaConfig_ModemPreset regionPresets[MAX_REGION_PRESETS];
|
||||
static uint8_t regionPresetCount = 0;
|
||||
|
||||
static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
{
|
||||
if (config.lora.region == region)
|
||||
@@ -177,24 +273,8 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (!owner.is_licensed) {
|
||||
bool keygenSuccess = false;
|
||||
|
||||
if (config.security.private_key.size == 32) {
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
} else {
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -202,7 +282,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
|
||||
initRegion();
|
||||
|
||||
if (myRegion && myRegion->dutyCycle < 100) {
|
||||
if (myRegion && getEffectiveDutyCycle() < 100) {
|
||||
config.lora.ignore_mqtt = true;
|
||||
}
|
||||
|
||||
@@ -349,17 +429,14 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
handleFreeText = true;
|
||||
cm.freeTextItem.rawText.erase(); // clear the previous freetext message
|
||||
freeTextMode = true; // render input field instead of normal menu
|
||||
// Open the on-screen keyboard only for full joystick devices
|
||||
if (settings->joystick.enabled && !inkhud->twoWayRocker)
|
||||
if (supportsFreeTextKeyboard(inkhud, settings))
|
||||
inkhud->openKeyboard();
|
||||
break;
|
||||
|
||||
case STORE_CANNEDMESSAGE_SELECTION:
|
||||
if (!settings->joystick.enabled || inkhud->twoWayRocker)
|
||||
cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry
|
||||
else
|
||||
cm.selectedMessageItem = &cm.messageItems.at(cursor - 2); // Minus two: offset for the "Send Ping" and free text entry
|
||||
break;
|
||||
case STORE_CANNEDMESSAGE_SELECTION: {
|
||||
const uint8_t prefixItems = supportsFreeTextKeyboard(inkhud, settings) ? 2 : 1;
|
||||
cm.selectedMessageItem = &cm.messageItems.at(cursor - prefixItems);
|
||||
} break;
|
||||
|
||||
case SEND_CANNEDMESSAGE:
|
||||
cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
|
||||
@@ -438,14 +515,27 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
break;
|
||||
|
||||
case TOGGLE_BACKLIGHT:
|
||||
// Note: backlight is already on in this situation
|
||||
// We're marking that it should *remain* on once menu closes
|
||||
assert(backlight);
|
||||
// Note: backlight is already on in this situation.
|
||||
// This toggle controls whether it should remain on when menu closes.
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
{
|
||||
const bool keepOn = !t5BacklightIsUserEnabled();
|
||||
t5BacklightSetUserEnabled(keepOn);
|
||||
saveT5BacklightKeepOn(keepOn);
|
||||
if (item.checkState)
|
||||
*(item.checkState) = keepOn;
|
||||
}
|
||||
#else
|
||||
if (!backlight)
|
||||
backlight = Drivers::LatchingBacklight::getInstance();
|
||||
if (backlight->isLatched())
|
||||
backlight->off();
|
||||
else
|
||||
backlight->latch();
|
||||
break;
|
||||
if (item.checkState)
|
||||
*(item.checkState) = backlight->isLatched();
|
||||
#endif
|
||||
break;
|
||||
|
||||
case TOGGLE_12H_CLOCK:
|
||||
config.display.use_12h_clock = !config.display.use_12h_clock;
|
||||
@@ -543,6 +633,17 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
}
|
||||
|
||||
// Display
|
||||
case SET_DISPLAY_TIMEOUT: {
|
||||
// cursor - 1 because index 0 is "Back"
|
||||
const uint8_t index = cursor - 1;
|
||||
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
|
||||
if (index < optionCount) {
|
||||
config.display.screen_on_secs = DISPLAY_TIMEOUT_OPTIONS[index].seconds;
|
||||
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TOGGLE_DISPLAY_UNITS:
|
||||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
|
||||
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
|
||||
@@ -675,6 +776,14 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_BR_902);
|
||||
break;
|
||||
|
||||
case SET_REGION_EU_866:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
|
||||
break;
|
||||
|
||||
case SET_REGION_NARROW_868:
|
||||
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868);
|
||||
break;
|
||||
|
||||
// Roles
|
||||
case SET_ROLE_CLIENT:
|
||||
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT);
|
||||
@@ -694,37 +803,46 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
|
||||
// Presets
|
||||
case SET_PRESET_LONG_SLOW:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW);
|
||||
applyLoRaPreset(PRESET(LONG_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_LONG_MODERATE:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE);
|
||||
applyLoRaPreset(PRESET(LONG_MODERATE));
|
||||
break;
|
||||
|
||||
case SET_PRESET_LONG_FAST:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
||||
applyLoRaPreset(PRESET(LONG_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_MEDIUM_SLOW:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW);
|
||||
applyLoRaPreset(PRESET(MEDIUM_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_MEDIUM_FAST:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
|
||||
applyLoRaPreset(PRESET(MEDIUM_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_SHORT_SLOW:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW);
|
||||
applyLoRaPreset(PRESET(SHORT_SLOW));
|
||||
break;
|
||||
|
||||
case SET_PRESET_SHORT_FAST:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST);
|
||||
applyLoRaPreset(PRESET(SHORT_FAST));
|
||||
break;
|
||||
|
||||
case SET_PRESET_SHORT_TURBO:
|
||||
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
|
||||
applyLoRaPreset(PRESET(SHORT_TURBO));
|
||||
break;
|
||||
|
||||
case SET_PRESET_FROM_REGION: {
|
||||
// cursor - 1 because index 0 is "Back"
|
||||
const uint8_t index = cursor - 1;
|
||||
if (index < regionPresetCount) {
|
||||
applyLoRaPreset(regionPresets[index]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Timezones
|
||||
case SET_TZ_US_HAWAII:
|
||||
applyTimezone("HST10");
|
||||
@@ -909,11 +1027,16 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
previousPage = MenuPage::ROOT;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
// Optional: backlight
|
||||
if (settings->optionalMenuItems.backlight)
|
||||
items.push_back(MenuItem(backlight->isLatched() ? "Backlight Off" : "Keep Backlight On", // Label
|
||||
MenuAction::TOGGLE_BACKLIGHT, // Action
|
||||
MenuPage::EXIT // Exit once complete
|
||||
));
|
||||
if (settings->optionalMenuItems.backlight) {
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
keepBacklightOn = t5BacklightIsUserEnabled();
|
||||
#else
|
||||
if (!backlight)
|
||||
backlight = Drivers::LatchingBacklight::getInstance();
|
||||
keepBacklightOn = backlight->isLatched();
|
||||
#endif
|
||||
items.push_back(MenuItem("Keep Backlight On", MenuAction::TOGGLE_BACKLIGHT, MenuPage::OPTIONS, &keepBacklightOn));
|
||||
}
|
||||
|
||||
// Options Toggles
|
||||
items.push_back(MenuItem("Applets", MenuPage::APPLETS));
|
||||
@@ -1125,6 +1248,9 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("12-Hour Clock", MenuAction::TOGGLE_12H_CLOCK, MenuPage::NODE_CONFIG_DISPLAY,
|
||||
&config.display.use_12h_clock));
|
||||
|
||||
nodeConfigLabels.emplace_back("Screen Timeout: " + std::string(getDisplayTimeoutLabel(config.display.screen_on_secs)));
|
||||
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DISPLAY_TIMEOUT));
|
||||
|
||||
const char *unitsLabel =
|
||||
(config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "Units: Imperial" : "Units: Metric";
|
||||
|
||||
@@ -1134,6 +1260,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
break;
|
||||
}
|
||||
|
||||
case NODE_CONFIG_DISPLAY_TIMEOUT:
|
||||
previousPage = MenuPage::NODE_CONFIG_DISPLAY;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
populateDisplayTimeoutPage();
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
case NODE_CONFIG_BLUETOOTH: {
|
||||
previousPage = MenuPage::NODE_CONFIG;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
@@ -1311,6 +1444,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("US", MenuAction::SET_REGION_US, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 868", MenuAction::SET_REGION_EU_868, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 433", MenuAction::SET_REGION_EU_433, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 866", MenuAction::SET_REGION_EU_866, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("EU 868 Narrow", MenuAction::SET_REGION_NARROW_868, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("CN", MenuAction::SET_REGION_CN, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("JP", MenuAction::SET_REGION_JP, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("ANZ", MenuAction::SET_REGION_ANZ, MenuPage::EXIT));
|
||||
@@ -1340,13 +1475,17 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
case NODE_CONFIG_PRESET: {
|
||||
previousPage = MenuPage::NODE_CONFIG_LORA;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
items.push_back(MenuItem("Long Moderate", MenuAction::SET_PRESET_LONG_MODERATE, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Long Fast", MenuAction::SET_PRESET_LONG_FAST, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Medium Slow", MenuAction::SET_PRESET_MEDIUM_SLOW, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Medium Fast", MenuAction::SET_PRESET_MEDIUM_FAST, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Short Slow", MenuAction::SET_PRESET_SHORT_SLOW, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Short Fast", MenuAction::SET_PRESET_SHORT_FAST, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Short Turbo", MenuAction::SET_PRESET_SHORT_TURBO, MenuPage::EXIT));
|
||||
regionPresetCount = 0;
|
||||
if (myRegion && myRegion->profile) {
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets();
|
||||
size_t numPresets = myRegion->getNumPresets();
|
||||
for (size_t i = 0; i < numPresets && regionPresetCount < MAX_REGION_PRESETS; ++i) {
|
||||
regionPresets[regionPresetCount++] = presets[i];
|
||||
const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true);
|
||||
nodeConfigLabels.emplace_back(name);
|
||||
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_PRESET_FROM_REGION, MenuPage::EXIT));
|
||||
}
|
||||
}
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
}
|
||||
@@ -1402,10 +1541,14 @@ void InkHUD::MenuApplet::onRender(bool full)
|
||||
if (items.size() == 0)
|
||||
LOG_ERROR("Empty Menu");
|
||||
|
||||
const bool touchFriendlyLayout = useTouchFriendlyMenuLayout(inkhud);
|
||||
AppletFont menuItemFont = touchFriendlyLayout ? fontMedium : fontSmall;
|
||||
setFont(menuItemFont);
|
||||
|
||||
// Dimensions for the slots where we will draw menuItems
|
||||
const float padding = 0.05;
|
||||
const uint16_t itemH = fontSmall.lineHeight() * 1.6;
|
||||
const int16_t selectInsetY = 2;
|
||||
const uint16_t itemH = getMenuItemHeightPx(inkhud);
|
||||
const int16_t selectInsetY = touchFriendlyLayout ? 3 : 2;
|
||||
const int16_t itemW = width() - X(padding) - X(padding);
|
||||
const int16_t itemL = X(padding);
|
||||
const int16_t itemR = X(1 - padding);
|
||||
@@ -1438,6 +1581,11 @@ void InkHUD::MenuApplet::onRender(bool full)
|
||||
itemT = max(siT + siH, 0); // Offset the first menu entry, so menu starts below the system info panel
|
||||
}
|
||||
|
||||
// drawSystemInfoPanel() changes font state (clock/info text).
|
||||
// Restore the active menu font so ROOT page item text matches other menu pages,
|
||||
// including touch-friendly layouts.
|
||||
setFont(menuItemFont);
|
||||
|
||||
// Draw menu items
|
||||
// ===================
|
||||
|
||||
@@ -1465,8 +1613,6 @@ void InkHUD::MenuApplet::onRender(bool full)
|
||||
|
||||
// Header (non-selectable section label)
|
||||
if (item.isHeader) {
|
||||
setFont(fontSmall);
|
||||
|
||||
// Header text (flush left)
|
||||
printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE);
|
||||
|
||||
@@ -1475,8 +1621,17 @@ void InkHUD::MenuApplet::onRender(bool full)
|
||||
drawLine(itemL + X(padding), underlineY, itemR - X(padding), underlineY, BLACK);
|
||||
} else {
|
||||
// Box, if currently selected
|
||||
if (cursorShown && i == cursor)
|
||||
drawRect(itemL, itemT + selectInsetY, itemW, itemH - (selectInsetY * 2), BLACK);
|
||||
if (cursorShown && i == cursor && (!touchFriendlyLayout || !hideTouchSelectionHighlight)) {
|
||||
const int16_t selTop = itemT + selectInsetY;
|
||||
const int16_t selH = itemH - (selectInsetY * 2);
|
||||
drawRect(itemL, selTop, itemW, selH, BLACK);
|
||||
// Touch layouts need a stronger visual cue than a thin outline.
|
||||
if (touchFriendlyLayout) {
|
||||
const int16_t markerInset = 3;
|
||||
const int16_t markerW = 4;
|
||||
fillRect(itemL + markerInset, selTop + markerInset, markerW, max(1, selH - (markerInset * 2)), BLACK);
|
||||
}
|
||||
}
|
||||
|
||||
// Indented normal item text
|
||||
printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE);
|
||||
@@ -1484,9 +1639,9 @@ void InkHUD::MenuApplet::onRender(bool full)
|
||||
|
||||
// Checkbox, if relevant
|
||||
if (item.checkState) {
|
||||
const uint16_t cbWH = fontSmall.lineHeight(); // Checkbox: width / height
|
||||
const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
|
||||
const int16_t cbT = center - (cbWH / 2); // Checkbox : top
|
||||
const uint16_t cbWH = menuItemFont.lineHeight(); // Checkbox: width / height
|
||||
const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
|
||||
const int16_t cbT = center - (cbWH / 2); // Checkbox : top
|
||||
// Checkbox ticked
|
||||
if (*(item.checkState)) {
|
||||
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
|
||||
@@ -1515,13 +1670,102 @@ void InkHUD::MenuApplet::onRender(bool full)
|
||||
}
|
||||
}
|
||||
|
||||
bool InkHUD::MenuApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
|
||||
{
|
||||
(void)longPress;
|
||||
|
||||
if (freeTextMode || !getTile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t tileL = getTile()->getLeft();
|
||||
const uint16_t tileT = getTile()->getTop();
|
||||
const uint16_t tileR = tileL + getTile()->getWidth();
|
||||
const uint16_t tileB = tileT + getTile()->getHeight();
|
||||
if (x < tileL || x >= tileR || y < tileT || y >= tileB) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (items.empty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direct touch controls should act as activity and keep the menu open.
|
||||
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
||||
|
||||
// If button-driven selection is active on touch-first layouts, clear it as soon as
|
||||
// touch interaction resumes so touch behavior remains direct/tap-first.
|
||||
if (useTouchFriendlyMenuLayout(inkhud)) {
|
||||
cursorShown = false;
|
||||
hideTouchSelectionHighlight = true;
|
||||
}
|
||||
|
||||
const int16_t localY = (int16_t)y - (int16_t)tileT;
|
||||
|
||||
// Keep geometry in sync with onRender() so touch hit-testing matches what users see.
|
||||
const uint16_t itemH = getMenuItemHeightPx(inkhud);
|
||||
int16_t itemT = 0;
|
||||
uint8_t slotCount = (height() - itemT) / itemH;
|
||||
if (slotCount == 0) {
|
||||
slotCount = 1;
|
||||
}
|
||||
const uint16_t &siH = systemInfoPanelHeight;
|
||||
const uint8_t slotsObscured = ceilf(siH / (float)itemH);
|
||||
|
||||
if (currentPage == ROOT) {
|
||||
int16_t siT = 0;
|
||||
const int16_t scrollThreshold = (int16_t)slotCount - (int16_t)slotsObscured - 1;
|
||||
if (scrollThreshold >= 0 && (int16_t)cursor >= scrollThreshold) {
|
||||
siT = 0 - ((cursor - scrollThreshold) * itemH);
|
||||
}
|
||||
itemT = max((int16_t)(siT + siH), (int16_t)0);
|
||||
}
|
||||
|
||||
const uint8_t firstItem = (cursor < slotCount) ? 0 : (cursor - (slotCount - 1));
|
||||
uint16_t visibleEnd = (uint16_t)firstItem + (uint16_t)slotCount;
|
||||
const uint8_t maxIndex = (uint8_t)items.size() - 1;
|
||||
if (visibleEnd > maxIndex) {
|
||||
visibleEnd = maxIndex;
|
||||
}
|
||||
const uint8_t lastItem = (uint8_t)visibleEnd;
|
||||
|
||||
for (uint8_t i = firstItem; i <= lastItem; i++) {
|
||||
const int16_t rowTop = itemT;
|
||||
const int16_t rowBottom = itemT + itemH;
|
||||
|
||||
if (localY >= rowTop && localY < rowBottom) {
|
||||
if (items.at(i).isHeader) {
|
||||
// Consume taps on headers so they don't fall back to button semantics.
|
||||
return true;
|
||||
}
|
||||
|
||||
cursor = i;
|
||||
cursorShown = true;
|
||||
execute(items.at(cursor));
|
||||
|
||||
if (!wantsToRender()) {
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
itemT += itemH;
|
||||
}
|
||||
|
||||
// Consume taps on menu whitespace so we don't trigger button-like fallback behavior.
|
||||
return true;
|
||||
}
|
||||
|
||||
void InkHUD::MenuApplet::onButtonShortPress()
|
||||
{
|
||||
if (!freeTextMode) {
|
||||
// Push the auto-close timer back
|
||||
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
||||
|
||||
if (!settings->joystick.enabled) {
|
||||
// Touch-first nodes keep user-button short-press as "advance selection" in menus.
|
||||
// Any button-driven navigation should restore visible highlight.
|
||||
hideTouchSelectionHighlight = false;
|
||||
if (!settings->joystick.enabled || useTouchFriendlyMenuLayout(inkhud)) {
|
||||
if (!cursorShown) {
|
||||
cursorShown = true;
|
||||
// Select the first item that isn't a header
|
||||
@@ -1582,6 +1826,32 @@ void InkHUD::MenuApplet::onNavUp()
|
||||
if (!freeTextMode) {
|
||||
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
||||
|
||||
// Touch-first menus: swipe up/down should scroll only.
|
||||
// Keep cursor movement for scroll math, but selection box is hidden in onRender().
|
||||
if (useTouchFriendlyMenuLayout(inkhud)) {
|
||||
hideTouchSelectionHighlight = true;
|
||||
if (!cursorShown) {
|
||||
cursorShown = true;
|
||||
cursor = items.size() - 1;
|
||||
while (items.at(cursor).isHeader) {
|
||||
if (cursor == 0) {
|
||||
cursorShown = false;
|
||||
break;
|
||||
}
|
||||
cursor--;
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
if (cursor == 0)
|
||||
cursor = items.size() - 1;
|
||||
else
|
||||
cursor--;
|
||||
} while (items.at(cursor).isHeader);
|
||||
}
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cursorShown) {
|
||||
cursorShown = true;
|
||||
// Select the last item that isn't a header
|
||||
@@ -1611,6 +1881,29 @@ void InkHUD::MenuApplet::onNavDown()
|
||||
if (!freeTextMode) {
|
||||
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
||||
|
||||
// Touch-first menus: swipe up/down should scroll only.
|
||||
// Keep cursor movement for scroll math, but selection box is hidden in onRender().
|
||||
if (useTouchFriendlyMenuLayout(inkhud)) {
|
||||
hideTouchSelectionHighlight = true;
|
||||
if (!cursorShown) {
|
||||
cursorShown = true;
|
||||
cursor = 0;
|
||||
while (cursor < items.size() && items.at(cursor).isHeader) {
|
||||
cursor++;
|
||||
}
|
||||
if (cursor >= items.size()) {
|
||||
cursorShown = false;
|
||||
cursor = 0;
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
cursor = (cursor + 1) % items.size();
|
||||
} while (items.at(cursor).isHeader);
|
||||
}
|
||||
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cursorShown) {
|
||||
cursorShown = true;
|
||||
// Select the first item that isn't a header
|
||||
@@ -1743,6 +2036,17 @@ void InkHUD::MenuApplet::populateRecentsPage()
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::MenuApplet::populateDisplayTimeoutPage()
|
||||
{
|
||||
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
|
||||
for (uint8_t i = 0; i < optionCount; i++) {
|
||||
displayTimeoutSelected[i] = (config.display.screen_on_secs == DISPLAY_TIMEOUT_OPTIONS[i].seconds);
|
||||
nodeConfigLabels.emplace_back(DISPLAY_TIMEOUT_OPTIONS[i].label);
|
||||
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_DISPLAY_TIMEOUT, MenuPage::NODE_CONFIG_DISPLAY,
|
||||
&displayTimeoutSelected[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// MenuItem entries for the "send" page
|
||||
// Dynamically creates menu items based on available canned messages
|
||||
void InkHUD::MenuApplet::populateSendPage()
|
||||
@@ -1750,8 +2054,8 @@ void InkHUD::MenuApplet::populateSendPage()
|
||||
// Position / NodeInfo packet
|
||||
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
|
||||
|
||||
// If joystick is available, include the Free Text option
|
||||
if (settings->joystick.enabled && !inkhud->twoWayRocker)
|
||||
// Show the Free Text option on any node that supports the on-screen keyboard.
|
||||
if (supportsFreeTextKeyboard(inkhud, settings))
|
||||
items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND));
|
||||
|
||||
// One menu item for each canned message
|
||||
@@ -1815,7 +2119,7 @@ void InkHUD::MenuApplet::populateRecipientPage()
|
||||
|
||||
// Count favorites
|
||||
for (uint32_t i = 0; i < nodeCount; i++) {
|
||||
if (nodeDB->getMeshNodeByIndex(i)->is_favorite)
|
||||
if (nodeInfoLiteIsFavorite(nodeDB->getMeshNodeByIndex(i)))
|
||||
favoriteCount++;
|
||||
}
|
||||
|
||||
@@ -1823,10 +2127,10 @@ void InkHUD::MenuApplet::populateRecipientPage()
|
||||
// Don't want some monstrous list that takes 100 clicks to reach exit
|
||||
if (favoriteCount < 20) {
|
||||
for (uint32_t i = 0; i < nodeCount; i++) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
||||
|
||||
// Skip node if not a favorite
|
||||
if (!node->is_favorite)
|
||||
if (!nodeInfoLiteIsFavorite(node))
|
||||
continue;
|
||||
|
||||
CannedMessages::RecipientItem r;
|
||||
@@ -1836,8 +2140,8 @@ void InkHUD::MenuApplet::populateRecipientPage()
|
||||
|
||||
// Set a label for the menu item
|
||||
r.label = "DM: ";
|
||||
if (node->has_user)
|
||||
r.label += parse(node->user.long_name);
|
||||
if (nodeInfoLiteHasUser(node))
|
||||
r.label += parse(node->long_name);
|
||||
else
|
||||
r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo?
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
|
||||
void onFreeText(char c) override;
|
||||
void onFreeTextDone() override;
|
||||
void onFreeTextCancel() override;
|
||||
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
|
||||
void onRender(bool full) override;
|
||||
|
||||
void show(Tile *t); // Open the menu, onto a user tile
|
||||
@@ -48,11 +49,12 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
|
||||
void execute(MenuItem item); // Perform the MenuAction associated with a MenuItem, if any
|
||||
void showPage(MenuPage page); // Load and display a MenuPage
|
||||
|
||||
void populateSendPage(); // Dynamically create MenuItems including canned messages
|
||||
void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
|
||||
void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
|
||||
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
|
||||
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
|
||||
void populateSendPage(); // Dynamically create MenuItems including canned messages
|
||||
void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
|
||||
void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
|
||||
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
|
||||
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
|
||||
void populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs
|
||||
|
||||
void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height,
|
||||
const std::string &text); // Draw input field for free text
|
||||
@@ -65,8 +67,9 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
|
||||
MenuPage startPageOverride = MenuPage::ROOT;
|
||||
MenuPage currentPage = MenuPage::ROOT;
|
||||
MenuPage previousPage = MenuPage::EXIT;
|
||||
uint8_t cursor = 0; // Which menu item is currently highlighted
|
||||
bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
|
||||
uint8_t cursor = 0; // Which menu item is currently highlighted
|
||||
bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
|
||||
bool hideTouchSelectionHighlight = false; // Touch scrolling keeps cursor for paging math, but can hide highlight
|
||||
bool freeTextMode = false;
|
||||
uint16_t systemInfoPanelHeight = 0; // Need to know before we render
|
||||
uint16_t menuTextLimit = 200;
|
||||
@@ -80,6 +83,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
|
||||
// Recents menu checkbox state (derived from settings.recentlyActiveSeconds)
|
||||
static constexpr uint8_t RECENTS_COUNT = 6;
|
||||
bool recentsSelected[RECENTS_COUNT] = {};
|
||||
static constexpr uint8_t DISPLAY_TIMEOUT_COUNT = 7;
|
||||
bool displayTimeoutSelected[DISPLAY_TIMEOUT_COUNT] = {};
|
||||
|
||||
// Data for selecting and sending canned messages via the menu
|
||||
// Placed into a sub-class for organization only
|
||||
@@ -116,7 +121,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
|
||||
|
||||
Applet *borrowedTileOwner = nullptr; // Which applet we have temporarily replaced while displaying menu
|
||||
|
||||
bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options
|
||||
bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options
|
||||
bool keepBacklightOn = false; // Helper to display current backlight latch state in InkHUD options
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
@@ -32,6 +32,7 @@ enum MenuPage : uint8_t {
|
||||
NODE_CONFIG_POWER_ADC_CAL,
|
||||
NODE_CONFIG_NETWORK,
|
||||
NODE_CONFIG_DISPLAY,
|
||||
NODE_CONFIG_DISPLAY_TIMEOUT,
|
||||
NODE_CONFIG_BLUETOOTH,
|
||||
NODE_CONFIG_POSITION,
|
||||
NODE_CONFIG_ADMIN_RESET,
|
||||
@@ -45,4 +46,4 @@ enum MenuPage : uint8_t {
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -241,7 +241,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
||||
text += msgIsBroadcast ? "From:" : "DM: ";
|
||||
|
||||
// Sender id
|
||||
if (node && node->has_user)
|
||||
if (nodeInfoLiteHasUser(node))
|
||||
text += parseShortName(node);
|
||||
else
|
||||
text += hexifyNodeNum(message->sender);
|
||||
@@ -255,7 +255,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
||||
text += msgIsBroadcast ? "Msg from " : "DM from ";
|
||||
|
||||
// Sender id
|
||||
if (node && node->has_user)
|
||||
if (nodeInfoLiteHasUser(node))
|
||||
text += parseShortName(node);
|
||||
else
|
||||
text += hexifyNodeNum(message->sender);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
#include "./TouchStatusApplet.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
InkHUD::TouchStatusApplet::TouchStatusApplet()
|
||||
{
|
||||
alwaysRender = true;
|
||||
}
|
||||
|
||||
void InkHUD::TouchStatusApplet::onRender(bool full)
|
||||
{
|
||||
(void)full;
|
||||
|
||||
if (inkhud->isTouchEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFont(fontSmall);
|
||||
|
||||
const uint16_t barH = fontSmall.lineHeight() + 4;
|
||||
const int16_t top = height() - barH;
|
||||
|
||||
fillRect(0, top, width(), barH, WHITE);
|
||||
drawLine(0, top, width() - 1, top, BLACK);
|
||||
printAt(width() / 2, top + (barH / 2), "TOUCH OFF", CENTER, MIDDLE);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
/*
|
||||
|
||||
Low-profile bottom-edge touch status indicator.
|
||||
Shown only while touch input is disabled.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "graphics/niche/InkHUD/SystemApplet.h"
|
||||
|
||||
namespace NicheGraphics::InkHUD
|
||||
{
|
||||
|
||||
class TouchStatusApplet : public SystemApplet
|
||||
{
|
||||
public:
|
||||
TouchStatusApplet();
|
||||
|
||||
void onRender(bool full) override;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user