Compare commits

...
Author SHA1 Message Date
Ben Meadors ee293c209d Merge branch 'develop' into macos 2026-04-26 21:05:17 -05:00
bfadf0c36a fix(Router): localize p_encrypted to prevent recursive-overwrite leak (#10311)
Router::handleReceived stores its allocCopy of the encrypted packet in the
class member p_encrypted. callModules() invokes module replies that re-enter
the router via MeshService::sendToMesh -> Router::sendLocal, which on a
broadcast reply recursively calls handleReceived. The inner call overwrites
the outer's p_encrypted without releasing it; on the way out it nulls the
member, the outer release(p_encrypted) now releases nullptr, and the original
allocation is permanently leaked. ~381 B per recursion.

Promote p_encrypted to a function-local so each invocation owns its own copy
for its full lifetime. The MQTT-publish null check at the call site (added by
PR #9136 as a workaround for this bug) stays in place because allocCopy can
still legitimately return nullptr on packetPool exhaustion.

Copilot's review of PR #8999 (the original introduction) flagged this exact
pattern at merge time:
  "Storing p_encrypted as a class member can cause issues with recursive or
  concurrent calls to handleReceived() since each call would overwrite the
  previous packet pointer."

The historical reason for the member (S&F needing to retain the encrypted
copy across calls) was satisfied differently by PR #9799 (ServerAPI converted
to std::unique_ptr + cleanup on connection close), so the member is no longer
load-bearing.

Reproduces issues #9632 / #10101 / #8729 (heap leak when MeshMonitor
connected; TCP drops on Station G2 / LILYGO ServerAPI dump abort).

Hardware A/B on Station G2 under sustained TCP-API retry storm (open :4403,
request config, disconnect mid-stream, repeat at ~0.6/s) - 9 min run:

  | Build         | heapFree drift | rebootCount delta |
  | this patch    | -1.5 KB (noise)| 0                 |
  | stock 2.7.13  | -73 KB (8.1KB/min) | +1 (OOM crash) |

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-26 21:02:42 -05:00
Ben Meadors 80ce9c4a75 Merge branch 'develop' into macos 2026-04-26 20:45:50 -05:00
copilot-swe-agent[bot]andthebentern 1d5b9361d8 fix: ensure null-termination in getSerialString() and handle len==0
Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e5647919-2255-48ad-bcaa-7a2c2fdbf212

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-27 01:43:53 +00:00
Ben MeadorsandCopilot 12285088d1 Update variants/native/portduino/platformio.ini
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 20:42:22 -05:00
Jonathan BennettandCopilot 0e05e715c0 Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 20:08:13 -05:00
Jonathan Bennett 24c4162a75 Standardize PMU IRQ handling and enable power button cancel on tbeam-s3 (#10285)
* Standardize PMU IRQ handling and enable power button as cancel on tbeam s3

* Original T-beam, too
2026-04-26 19:58:23 -05:00
Ben Meadors b148fac340 Update framework version reference for Adafruit nRF52 to latest master branch 2026-04-26 09:49:39 -05:00
8dde4eeee1 BaseUI: Color Support for TFT Nodes (#10233)
* True Colors on TFT (Heltec Mesh Node T114, Heltec Vision Master T190, CardPuter Adv, T-Deck, T-Lora Pager)

* Theme support - New and some Classic Themes!

* Colored Compass

---------

Co-authored-by: Jason P <applewiz@mac.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-26 07:44:56 -05:00
Ben Meadors 5b5ebe6fb4 Native MacOS hello world 2026-04-26 07:09:14 -05:00
Ben Meadors 4d4e14600c Merge remote-tracking branch 'origin/master' into develop 2026-04-25 15:55:16 -05:00
Ben Meadors aec0805a27 Trying another guard approach 2026-04-25 15:32:19 -05:00
Ben Meadors 0bd8dee346 Merge remote-tracking branch 'origin/master' into develop 2026-04-25 15:06:28 -05:00
Ben Meadors 7800dc3c8d Enhance UTF-8 sanitization logic and add delays in test setup for reliable timing 2026-04-25 15:04:58 -05:00
Ben Meadors 554188e90e Fix main function to setup and loop for Unity test framework 2026-04-25 14:49:37 -05:00
renovate[bot] fb678b9337 Update platform-native digest to 135b91e (#10300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-25 14:44:06 -05:00
2828dbe4ca t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp ...again (#10297)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns t5s3_epaper with other variants like t_deck_pro.

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-25 13:36:31 -04:00
Ben Meadors a8c8fd7002 Remove redundant power interrupt methods for ESP32 2026-04-25 07:11:03 -05:00
Ben MeadorsandCopilot e7c02da24b Merge remote-tracking branch 'origin/master' into develop
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 06:41:38 -05:00
Ben Meadors 7347091055 Add script to show unmerged commits from develop to master 2026-04-25 06:10:45 -05:00
github-actions[bot]andthebentern 55f15076ca Update protobufs (#10295)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-25 06:08:07 -05:00
HarukiToreda 7421953e8f InkHUD: Add full touch support to T5s3 (#10286)
* InkHUD touch rework

* Applet Switcher

* Update ED047TC1.cpp

* trunk fix

* Custom tip screen for T5s3

* Update TouchScreenImpl1.cpp

* Update ED047TC1.cpp

* Delete variant.cpp
2026-04-25 05:22:24 -05:00
GeorgeandClaude Sonnet 4.6 9306e66067 fix(inkhud): scale MapApplet markers with fontSmall line height (#10288)
Marker boxes, the own-node bullseye, and the labeled-marker cross were
all hardcoded in pixels (11px box, r=8 circle, 12px cross). On the
T5S3 with a 12pt fontSmall (~17px line height) the hop-count digit
overflowed its box entirely. Sizes now derive from fontSmall.lineHeight()
so the applet renders correctly on both small (6pt) and large (12pt+)
display variants.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:20:39 -04:00
Jonathan Bennett 439b87b860 Detach power interrupts for sleep (#10230)
* Detach power interrupts for sleep

* Gate PMU IRQ behind a found PMU
2026-04-24 06:48:21 -05:00
Jonathan BennettandBen Meadors d47301defc Add PortduinoSetOptions to overwrite the realhardware bool (#10157)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-24 06:48:09 -05:00
Jonathan Bennett 04b819a7b5 Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280)
Fixes #9912  and #10170
2026-04-24 06:47:38 -05:00
github-actions[bot]andvidplace7 8e653122c7 Upgrade trunk (#10284)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-24 05:40:44 -05:00
Jonathan Bennett 7adfc3f992 Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280)
Fixes #9912  and #10170
2026-04-24 15:17:33 +10:00
DmitryandCopilot Autofix powered by AI ba9cadc14d Fix INA226 detection for non-TI compatible chip (Silergy) (#10247)
* Fix INA226 detection for non-TI compatible chip (Silergy)

* Removed extra I2C transaction + 20ms delay on every scan of address 0x40 (including real SHT2x sensors).

Changes suggested by Copilot

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Apply formatting (trunk fmt)

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-24 13:09:37 +10:00
EmanueleandJonathan Bennett 924411de59 T-Watch S3 Power button managment (#9855)
* PMU interrupt pin defined in t-watch s3

* Implement button control on T-Watch S3

Added interrupt handling for the Power/Corona button on T-Watch S3, I use it to control screen state.

* Reducing labels

* Reducing labels

* Updated the comment

* ISR is now IRAM-safe

Updated interrupt management not to cause random crashes.

* Trunk

* Simplify and use INPUT_BROKER_CANCEL

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-23 19:53:59 -05:00
renovate[bot] 5ea3d143da Update meshtastic-esp8266-oled-ssd1306 digest to 6bfd1f1 (#10277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 19:30:47 -05:00
d9195944df PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc (#10251)
* PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc

The lost-and-found message was built with an unnecessary heap allocation:

    char *message = new char[60];
    sprintf(message, "..."...);
    ...
    delete[] message;

Two problems:

1. **Buffer too small.** The format string expands with two %f (IEEE 754
   doubles), which `sprintf` prints with full precision — easily 15+
   digits each plus separators — so the actual rendered string can run
   40-50 characters before even considering the emoji (4 UTF-8 bytes)
   and the embedded BEL. A pathological lat/lon can overflow 60 bytes
   and corrupt heap metadata. Unbounded `sprintf` with no size check.

2. **Heap churn in a GPS callback.** This function is called from the
   position-update path which is already heap-sensitive. An infrequent
   60-byte transient alloc isn't catastrophic, but stack is trivially
   available here and removes the failure mode entirely.

Fix: replace with a 128-byte stack buffer and `snprintf` bounded by
`sizeof(message)`. Drop the matching `delete[]` since there's nothing
to delete.

Behavior is identical on the happy path; the overflow case now
truncates safely instead of scribbling over heap.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* PositionModule.cpp: add trailing newline for clang-format

* Address Copilot review: cleaner snprintf size handling

Review feedback from @Copilot on PR #10251: the ternary-plus-static-cast
form mixed signed/unsigned types (int written vs. pb_size_t payload.size
vs. size_t sizeof(message)) and was harder to read than necessary.

Cleaner form:
  const size_t msg_len = std::min(static_cast<size_t>(written), sizeof(message) - 1);
  p->decoded.payload.size = msg_len;

Same behaviour (clamp to buffer-minus-NUL) with one explicit cast and
a size_t variable that names the meaning. Handles the encoding-error
path (written < 0) separately so no bad values leak into payload.size.

* Trunk

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-23 18:20:07 -05:00
83a98c81f6 Hash table index for O(1) packet history lookups (#9499)
* Use hash table for O(1) lookup of recently seen packets

* Eliminate a packet lookup during deduplication

* Infinite loop checks for find and remove

* Consolidate conditional compilation

* Exclude hash table from minimal build

* Additional comment on hash table capacity

* Unit tests for packet history changes

* Update incorrect comment about size clamp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Const

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 18:04:34 -05:00
Jonathan Bennett 837637b70c Only enable wakeup via EXT_CHRG_DETECT if we shut down due to low power (#10263) 2026-04-23 15:37:35 -05:00
Valentin V. Bartenev 7b3f58875a Fix example comment in airtime.h (#10275)
Looks like a copy'n'paste typo from the previous line.
It definitely meant to be RX_ALL_LOG according to comment.
2026-04-23 14:44:39 -05:00
Ben MeadorsandCopilot 56c897e826 Can't LOG when we don't have logging set up yet in the native test suite
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 14:42:29 -05:00
Andrew Yong b2d980fc25 feat(Power): support EXT_PWR_DETECT_MODE & EXT_PWR_DETECT_VALUE, simplify EXT_PWR_DETECT (#10140)
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-04-23 14:32:17 -05:00
Ben Meadors 2cc13a1132 Sane sanitization 2026-04-23 14:19:33 -05:00
Andrew YongandJonathan Bennett 2ed7bba5e7 fix(Power): refactor EXT_CHRG_DETECT to compile-time macros (#10191)
Mirror the EXT_PWR_DETECT pattern: replace runtime static variables
(ext_chrg_detect_mode, ext_chrg_detect_value) with compile-time macros.
Auto-infer EXT_CHRG_DETECT_VALUE from EXT_CHRG_DETECT_MODE when the mode
is INPUT_PULLUP (→ LOW) or INPUT_PULLDOWN (→ HIGH); default to HIGH.

This fixes inverted polarity on variants that define EXT_CHRG_DETECT_MODE
INPUT_PULLUP without an explicit EXT_CHRG_DETECT_VALUE (e.g. russell):
previously the runtime default of HIGH caused isCharging() to return the
opposite of the correct value. With auto-inference the correct LOW active
level is now derived at compile time.

Remove the now-redundant EXT_CHRG_DETECT_VALUE HIGH from ELECROW-ThinkNode-M4
variant.h since HIGH is the inferred default.


Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <noreply@example.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-23 13:24:05 -05:00
Jonathan Bennett 031f332ec1 We have HardwareRNG, let's use it! (#10274) 2026-04-23 13:16:49 -05:00
Catalin Patulea 22d50fe437 NimbleBluetooth misc cleanups (#10264)
* Delete unused clearNVS() (last used in commit 761804b1).

* virtual methods: add 'override' to ensure we get the signature right.

This is a safety net for pioarduino/NimBLE work where there's multiple
similar variants of the same method (eg. onConnect) and it's easy to get
the wrong one and accidentally miss a callback.
2026-04-23 09:18:41 -05:00
Ben Meadors cde5a08bc5 Merge remote-tracking branch 'origin/master' into develop 2026-04-23 06:48:43 -05:00
nightjoker7andBen Meadors 55bf8c25fc PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration (#10256)
* PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration

The STATE_SEND_MODULECONFIG state machine iterates config_state through
ModuleConfigType enum values (1..MAX+1 = 16) and switches on
meshtastic_ModuleConfig_*_tag values. Two of the resulting tag values
land in the default / LOG_ERROR path:

1. `tak_tag` (16) — the meshtastic_ModuleConfig_TAKConfig struct exists
   in the protobuf and has a `.tak` member in payload_variant, but no
   PhoneAPI case ever sends it to the phone. As a result, Android
   clients can't read the persisted TAK (Team Awareness Kit) module
   config at all. Added case that sends moduleConfig.tak, matching the
   pattern used for all other module-config tags (paxcounter,
   traffic_management, etc.). NodeDB already persists the struct via
   the moduleConfig protobuf save; this just wires the read path to
   the phone.

2. Tag 14 — reserved gap in the oneof numbering. No payload_variant
   member exists at tag 14. Without this patch, every phone reconnect
   walks through config_state=14 and hits
   `LOG_ERROR("Unknown module config type %d", config_state)`. On an
   active deployment that's ~1,400 LOG_ERROR lines per day per node —
   burying real errors. Added explicit `case 14: break;` so the gap
   is silently skipped.

Also: lowered the `default:` log level from LOG_ERROR to LOG_DEBUG. A
truly-new unknown type number would indicate firmware lagging the
protobuf — annoying but not an error event worth LOG_ERROR, especially
since this path runs on every phone handshake. If a new ModuleConfig
tag appears, devs will notice via the phone UI missing it, not via log.

Observed on a Station G2 fleet: 1403 "Unknown module config type 16"
and 1427 "Unknown module config type 14" LOG_ERROR lines in 24 hours
from routine phone reconnects.

* Get rid of the placeholder

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 06:42:05 -05:00
nightjoker7 399dde0f4b Router: demote cross-channel decrypt failures from ERROR to DEBUG (#10259)
The "Invalid protobufs (bad psk?)" and "Invalid portnum (bad psk?)"
messages fire every time a neighbor transmits on a channel whose
8-bit hash matches one of ours but the PSK differs. In RF environments
with multiple mesh groups nearby this is routine — a single device
can see dozens of these per minute from SAR/MeshCA/private networks
sharing a hash collision.

LOG_ERROR for a benign "not our PSK" event:
- spams the log when you have any neighboring mesh group
- makes a genuine PSK misconfiguration on YOUR own channel
  indistinguishable from the constant cross-channel noise
- hides actual errors in the stream

LOG_DEBUG matches how similar decryption-failure paths are handled
elsewhere (eg. the PKC "decrypt attempted but failed" uses LOG_WARN).
Dropping the trailing "!" as well — these are expected events, not
exceptional ones.
2026-04-23 06:19:05 -05:00
github-actions[bot]andvidplace7 48747ee43d Upgrade trunk (#10266)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-23 06:16:23 -05:00
nightjoker7andBen Meadors 66971a0a26 RadioLibInterface: clear static instance on destruction to prevent UAF (#10254)
The constructor sets `RadioLibInterface::instance = this` immediately,
before `init()` runs. `initLoRa()` in RadioInterface.cpp creates each
radio variant with `new SX1262Interface(...)` or similar, then calls
`init()`, and if init fails the `unique_ptr<RadioInterface>` is reset
to nullptr — destroying the object — while the static `instance`
pointer continues to point at the freed memory.

Main loop then checks `RadioLibInterface::instance != nullptr` and
calls `pollMissedIrqs()` or `resetAGC()` on the dangling pointer →
Guru Meditation (IllegalInstruction / LoadProhibited).

Reported in #9880 on an ESP32-S3 dev board without radio hardware
attached, where init always fails and the leftover pointer crashes
the device on the next `loop()` iteration.

Fix: add a virtual destructor to `RadioLibInterface` that clears the
static pointer iff it still references this object. A later successful
init() may have replaced `instance` with a different interface — the
`instance == this` guard preserves that case.

Fixes #9880

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 06:16:08 -05:00
Thomas Göttgens 4c24218afb Revert "Update LovyanGFX to v1.2.20 (#10232)" (#10269) (#10270)
This reverts commit fb1de111d7.
2026-04-23 12:40:27 +02:00
Thomas Göttgens 7c27f4e2df Revert "Update LovyanGFX to v1.2.20 (#10232)" (#10269)
This reverts commit fb1de111d7.
2026-04-23 12:37:05 +02:00
Ben Meadors 4b4914736f Merge remote-tracking branch 'origin/master' into develop 2026-04-22 21:52:25 -05:00
Jonathan Bennett 92c0133ef9 Finish evil merge cleanup (#10253) 2026-04-22 21:47:20 -05:00
nightjoker7 a6b1a69630 StoreForwardModule::historyAdd: memcpy source size, not buffer capacity (#10250)
`memcpy(... p.payload.bytes, meshtastic_Constants_DATA_PAYLOAD_LEN)`
reads past the actual payload when the incoming packet's payload is
shorter than `DATA_PAYLOAD_LEN` (237 bytes). The code just above
already records the correct size:

    this->packetHistory[...].payload_size = p.payload.size;

but then the memcpy ignores that and copies the full buffer capacity,
pulling uninitialized / adjacent memory bytes into the history entry.
Those extra bytes are later rebroadcast whenever the Store & Forward
module replays the packet.

Fix: memcpy using `p.payload.size` (the actual payload length) instead
of the constant buffer capacity.

Classification: bounded out-of-bounds READ into the protobuf scratch
buffer. Not directly exploitable for RCE (the destination buffer is
also DATA_PAYLOAD_LEN), but leaks adjacent memory into replayed
packets and is a latent correctness bug.
2026-04-22 21:04:37 -05:00
Ben Meadors 6171ad8c14 Remove duplicate GPIO init block in setup() from bad merge in PR #9378
The T-Deck-Pro V1.1 PR (#9378) had a backwards merge (e7b66281) that pulled
236 commits of master INTO a 1-commit fork branch, then a second merge
(8fd0a7f2) duplicated the entire T_DECK/T_DECK_PRO/T_LORA_PAGER/HACKADAY
peripheral init block in setup(). Also removes a stray
digitalWrite(BLE_LED, LED_STATE_OFF) in the HACKADAY section that was an
artifact of the evil merge at e7b66281.
2026-04-22 19:35:13 -05:00
Jonathan Bennett 28e705de5c Detach power interrupts for sleep (#10230)
* Detach power interrupts for sleep

* Gate PMU IRQ behind a found PMU
2026-04-22 14:27:48 -05:00
Jason P d8b11f0b14 Improve options to align to names of UI options (#10240) 2026-04-22 11:42:25 -05:00
Austin fcb9ec0c2d t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp (#10241)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns t5s3_epaper with other variants like t_deck_pro.
2026-04-22 11:42:02 -05:00
Austin a4b55bc6f2 cardputer-adv: Move variant.cpp -> extra_variants/variant.cpp (#10242)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns cardputer-adv with other variants like t_deck_pro.
2026-04-22 11:41:49 -05:00
github-actions[bot]andvidplace7 a710841723 Upgrade trunk (#10236)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-22 11:35:07 -05:00
renovate[bot] fb1de111d7 Update LovyanGFX to v1.2.20 (#10232)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 11:34:48 -05:00
Jonathan Bennett b53fe7a1e7 T watch pinfix (#10231)
* Minor button debugging bits

* pin0 is a pin, pin -1 means disabled
2026-04-21 20:03:40 -05:00
Jonathan Bennett 2b5daf2438 T watch pinfix (#10231)
* Minor button debugging bits

* pin0 is a pin, pin -1 means disabled
2026-04-21 20:02:56 -05:00
Tom db9fdd6794 Fix: filter out SKIPPED tests in PlatformIO output to improve log clarity (#10214) 2026-04-21 16:43:35 -05:00
9361b85f47 feat(t5s3-epaper): add InkHUD port for LilyGo T5 E-Paper S3 Pro (#10211)
* niche: add InkHUD port for LilyGo T5-E-Paper-S3-Pro (ED047TC1)

Add a NicheGraphics EInk driver adapter for the 4.7" ED047TC1 parallel
e-paper display used on the T5-E-Paper-S3-Pro (H752-01). The driver
wraps FastEPD and handles the polarity difference between InkHUD's
buffer format (0xFF = white) and FastEPD's (0x00 = white).

Rewrite variants/esp32s3/t5s3_epaper/nicheGraphics.h which was an
incomplete copy of the Heltec VM-E290 setup referencing undefined SPI
pin macros and a non-existent BUTTON_PIN_SECONDARY. The board uses a
parallel display, not the small SPI DEPG0290BNS800 that was referenced.

* fix: guard inputBroker null dereference in TouchScreenImpl1::init()

When MESHTASTIC_EXCLUDE_INPUTBROKER is defined (e.g. InkHUD builds),
inputBroker is nullptr. Calling inputBroker->registerSource() in that
state caused a LoadProhibited panic on any board that has both
HAS_TOUCHSCREEN=1 and the InputBroker excluded.

Add a null check before registerSource() to prevent the crash.

* niche: fix display rotation for T5-E-Paper-S3-Pro InkHUD port

Set rotation=3 (270° CW) in nicheGraphics.h to correct for FastEPD
scanning the ED047TC1 panel in portrait orientation, resulting in
correct landscape display output.

* fix: update buffer format descriptions and remove polarity inversion for InkHUD and FastEPD

* fix: update ED047TC1 driver to handle inactive pixel borders and adjust safe-area dimensions

* fix: comment out ruler diagnostic for E-Ink driver

* feat: implement TouchInkHUDBridge for direct touch event handling in InkHUD

* niche: add FreeSans 18pt/24pt Win1253 (Greek) fonts for larger InkHUD displays

Add Win1253-encoded FreeSans 18pt and 24pt font headers to support Greek
script on larger InkHUD screens (e.g., the 4.7" ED047TC1 at ~234 DPI).
Register FREESANS_24PT_WIN1253 and FREESANS_18PT_WIN1253 macros in AppletFont.h.
Set fontLarge=24pt, fontMedium=18pt, fontSmall=12pt in nicheGraphics.h for the
T5-E-Paper-S3-Pro.

* feat(ed047tc1): use true partial update for FAST refresh

Replace fullUpdate(CLEAR_FAST) with partialUpdate() for FAST display
updates. FastEPD's partialUpdate() diffs pCurrent against pPrevious
and only applies the update waveform to rows that have changed, leaving
unchanged rows with a neutral signal.

This reduces visible flicker on routine updates (new messages, position
changes) — only the affected region of the screen refreshes. Full-screen
CLEAR_SLOW updates are preserved for periodic ghosting cleanup, driven
by InkHUD's setDisplayResilience() ratio.

* feat(t5s3-epaper): enable frontlight via LatchingBacklight

Wire up BOARD_BL_EN (GPIO11) to InkHUD's LatchingBacklight driver.
Enable the backlight menu item so users can toggle "Keep Backlight On"
via Settings. The backlight turns on automatically when the menu opens
and off when it closes.

* Fix RTC chip (PCF8563 not PCF85063) and GT911 I2C address collision

- variant.h used PCF85063_RTC but the board has a PCF8563. The difference
  is the RAM register: PCF85063 has 1 byte of RAM; PCF8563 does not. The
  PCF85063 driver was trying to write this register on init, failing every
  time, and setDateTime writes were silently discarded — RTC time was
  never persisted across reboots. Switch to PCF8563_RTC/PCF8563_INT.

  Before:
    [E][SensorPCF85063.hpp:375] initImpl(): Failed to write to RAM memory
      register. Maybe this chip is pcf8563.
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:23
    PCF85063 setDateTime 2026-04-05 18:40:59
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:19  ← lost

  After:
    PCF8563 found at address 0x51
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:37  ← persisted
    PCF8563 setDateTime 2026-04-05 18:58:44
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:44  ← round-trips

- GT911 touch was initialized with GT911_SLAVE_ADDRESS_L (0x5D), which
  collides with the SFA30 air quality sensor also at 0x5D on the same
  I2C bus. Switch to GT911_SLAVE_ADDRESS_H (0x14): the library drives
  INT high during reset to program the GT911 to address 0x14,
  eliminating the address conflict.

  Before:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:568] initImpl(): Try using 0x5D as the device address

  After:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:544] initImpl(): Try using 0x14 as the device address

* t5s3_epaper: fix GT911 ghost-SFA30 via early I2C address latch

Investigation findings
----------------------
Boot logs showed "SFA30 found at address 0x5d" on every cold power-on,
and AirQualityTelemetry was registering an SFA30 sensor. However, every
readMeasuredValues() call returned error 268 (0x010C = Sensirion
WriteError | I2cAddressNack), meaning the I2C write to 0x5D was being
NACK'd — inconsistent with a real SFA30.

Root cause: the GT911 touch controller latches its I2C address from the
INT pin level at reset time (GT911 datasheet §4.3). GPIO3 (INT) defaults
LOW on ESP32-S3 cold boot → GT911 always powers up at 0x5D
(SLAVE_ADDRESS_L). The I2C scanner runs before lateInitVariant() had a
chance to reprogram the chip.

The scanner's SFA30 detection (ScanI2CTwoWire.cpp) sends the 2-byte
command 0xD060 to 0x5D and requests 48 bytes back. GT911 ACKs the
write (treating it as a register address) and returns 48 bytes of
register data, passing the length check — a false-positive SFA30
detection.

Confirmed via second cold-boot log: after the previous commit moved GT911
to 0x14 in lateInitVariant(), address 0x5D *still* appeared in the scan
because the scanner runs first. The board has no physical SFA30 fitted.

Fix
---
Add the GT911 address-latch reset sequence to earlyInitVariant(), before
Wire is initialised and before the I2C scan runs. Per the datasheet:
drive RST LOW, drive INT HIGH (selects address 0x14 / SLAVE_ADDRESS_H),
hold >100 µs, release RST, wait >5 ms startup. GPIO-only, no Wire
dependency. lateInitVariant() then repeats this sequence internally via
touch.begin(); the double-reset is harmless.

Verified in boot log:
  Before: "SFA30 found at address 0x5d", 5 I2C devices, NACK errors
  After:  no SFA30 entry, 4 I2C devices (TCA9535/PCF8563/BQ27220/BQ25896),
          GT911 found at 0x14 and touch initialised successfully,
          AirQualityTelemetry registers no sensors (correct — no SFA30 present)

* t5s3_epaper: add variant_shutdown() for touch sleep and backlight off

Put GT911 into low-power standby (command 0x05) and drive BOARD_BL_EN
LOW before deep sleep to avoid unnecessary current draw.

* t5s3_epaper: fix touch gesture routing and coordinate mapping

readTouch() now transforms raw GT911 axes to visual-frame coordinates
based on the current display rotation (rotation=3 is the hardware
identity). This ensures TouchScreenBase detects swipe direction
correctly regardless of which rotation the user has selected.

TouchInkHUDBridge dynamically sets joystick.alignment = (4-rotation)%4
on each touch event so that (rotation+alignment)%4==0 always, keeping
nav calls pass-through without remapping.

nicheGraphics.h now calls loadSettings() first so that rotation is
persisted across reboots. rotation=3 and other first-boot defaults are
only applied when tips.firstBoot is set. alignment is recomputed from
the loaded rotation on every boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix GT911 sleep timing via notifyDeepSleep observer

touch.sleep() was called from variant_shutdown(), which runs inside
cpuDeepSleep() — after Wire.end() had already torn down the I2C bus in
doDeepSleep(). This caused Wire NULL TX buffer errors and left the GT911
awake during deep sleep.

Register a CallbackObserver on notifyDeepSleep, which fires before
Wire.end(), so the I2C command reaches the chip while the bus is live.
Pattern matches LatchingBacklight and other NicheGraphics components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix touch nav and applet defaults in nicheGraphics

Enable joystick mode post-begin so menu scroll and swipe-up/down
gestures are not silently dropped by the joystick.enabled gate in
Events.cpp. Activate DMs and Channel 0/1 applets with correct
autoshow defaults matching the mini-epaper-s3 reference pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update nicheGraphics.h

* t5s3_epaper: fix ED047TC1 driver docs and remove spurious beginPolling

Addressing PR review comments:

Remove beginPolling(1, 0) after the blocking FastEPD update — it
incorrectly set updateRunning=true for one loop cycle after the
hardware was already done, causing busy() to briefly return true.
Since isUpdateDone() always returns true, no polling is needed.

Also fix stale comments: safe-area buffer size was 944×532, now
944×523; V_OFFSET_ROWS didn't exist, replaced with the actual
V_OFFSET_TOP=9 / V_OFFSET_BOTTOM=8 constant names.

* t5s3_epaper: clean up applet addition formatting in setupNicheGraphics

* t5s3_epaper: guard ED047TC1.cpp against non-T5S3 InkHUD builds

The InkHUD base config pulls in all of src/graphics/niche/ so every
InkHUD device compiled ED047TC1.cpp, triggering the #error on line 48
for boards that define neither T5_S3_EPAPER_PRO_V1 nor V2.

Wrap the file body with #ifdef T5_S3_EPAPER_PRO so it is only compiled
for T5S3 targets. The #error is preserved inside the guard to catch
future hardware revisions that forget to update the driver.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-04-21 15:35:28 -05:00
3b4c66439d feat(t5s3-epaper): add InkHUD port for LilyGo T5 E-Paper S3 Pro (#10211)
* niche: add InkHUD port for LilyGo T5-E-Paper-S3-Pro (ED047TC1)

Add a NicheGraphics EInk driver adapter for the 4.7" ED047TC1 parallel
e-paper display used on the T5-E-Paper-S3-Pro (H752-01). The driver
wraps FastEPD and handles the polarity difference between InkHUD's
buffer format (0xFF = white) and FastEPD's (0x00 = white).

Rewrite variants/esp32s3/t5s3_epaper/nicheGraphics.h which was an
incomplete copy of the Heltec VM-E290 setup referencing undefined SPI
pin macros and a non-existent BUTTON_PIN_SECONDARY. The board uses a
parallel display, not the small SPI DEPG0290BNS800 that was referenced.

* fix: guard inputBroker null dereference in TouchScreenImpl1::init()

When MESHTASTIC_EXCLUDE_INPUTBROKER is defined (e.g. InkHUD builds),
inputBroker is nullptr. Calling inputBroker->registerSource() in that
state caused a LoadProhibited panic on any board that has both
HAS_TOUCHSCREEN=1 and the InputBroker excluded.

Add a null check before registerSource() to prevent the crash.

* niche: fix display rotation for T5-E-Paper-S3-Pro InkHUD port

Set rotation=3 (270° CW) in nicheGraphics.h to correct for FastEPD
scanning the ED047TC1 panel in portrait orientation, resulting in
correct landscape display output.

* fix: update buffer format descriptions and remove polarity inversion for InkHUD and FastEPD

* fix: update ED047TC1 driver to handle inactive pixel borders and adjust safe-area dimensions

* fix: comment out ruler diagnostic for E-Ink driver

* feat: implement TouchInkHUDBridge for direct touch event handling in InkHUD

* niche: add FreeSans 18pt/24pt Win1253 (Greek) fonts for larger InkHUD displays

Add Win1253-encoded FreeSans 18pt and 24pt font headers to support Greek
script on larger InkHUD screens (e.g., the 4.7" ED047TC1 at ~234 DPI).
Register FREESANS_24PT_WIN1253 and FREESANS_18PT_WIN1253 macros in AppletFont.h.
Set fontLarge=24pt, fontMedium=18pt, fontSmall=12pt in nicheGraphics.h for the
T5-E-Paper-S3-Pro.

* feat(ed047tc1): use true partial update for FAST refresh

Replace fullUpdate(CLEAR_FAST) with partialUpdate() for FAST display
updates. FastEPD's partialUpdate() diffs pCurrent against pPrevious
and only applies the update waveform to rows that have changed, leaving
unchanged rows with a neutral signal.

This reduces visible flicker on routine updates (new messages, position
changes) — only the affected region of the screen refreshes. Full-screen
CLEAR_SLOW updates are preserved for periodic ghosting cleanup, driven
by InkHUD's setDisplayResilience() ratio.

* feat(t5s3-epaper): enable frontlight via LatchingBacklight

Wire up BOARD_BL_EN (GPIO11) to InkHUD's LatchingBacklight driver.
Enable the backlight menu item so users can toggle "Keep Backlight On"
via Settings. The backlight turns on automatically when the menu opens
and off when it closes.

* Fix RTC chip (PCF8563 not PCF85063) and GT911 I2C address collision

- variant.h used PCF85063_RTC but the board has a PCF8563. The difference
  is the RAM register: PCF85063 has 1 byte of RAM; PCF8563 does not. The
  PCF85063 driver was trying to write this register on init, failing every
  time, and setDateTime writes were silently discarded — RTC time was
  never persisted across reboots. Switch to PCF8563_RTC/PCF8563_INT.

  Before:
    [E][SensorPCF85063.hpp:375] initImpl(): Failed to write to RAM memory
      register. Maybe this chip is pcf8563.
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:23
    PCF85063 setDateTime 2026-04-05 18:40:59
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:19  ← lost

  After:
    PCF8563 found at address 0x51
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:37  ← persisted
    PCF8563 setDateTime 2026-04-05 18:58:44
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:44  ← round-trips

- GT911 touch was initialized with GT911_SLAVE_ADDRESS_L (0x5D), which
  collides with the SFA30 air quality sensor also at 0x5D on the same
  I2C bus. Switch to GT911_SLAVE_ADDRESS_H (0x14): the library drives
  INT high during reset to program the GT911 to address 0x14,
  eliminating the address conflict.

  Before:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:568] initImpl(): Try using 0x5D as the device address

  After:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:544] initImpl(): Try using 0x14 as the device address

* t5s3_epaper: fix GT911 ghost-SFA30 via early I2C address latch

Investigation findings
----------------------
Boot logs showed "SFA30 found at address 0x5d" on every cold power-on,
and AirQualityTelemetry was registering an SFA30 sensor. However, every
readMeasuredValues() call returned error 268 (0x010C = Sensirion
WriteError | I2cAddressNack), meaning the I2C write to 0x5D was being
NACK'd — inconsistent with a real SFA30.

Root cause: the GT911 touch controller latches its I2C address from the
INT pin level at reset time (GT911 datasheet §4.3). GPIO3 (INT) defaults
LOW on ESP32-S3 cold boot → GT911 always powers up at 0x5D
(SLAVE_ADDRESS_L). The I2C scanner runs before lateInitVariant() had a
chance to reprogram the chip.

The scanner's SFA30 detection (ScanI2CTwoWire.cpp) sends the 2-byte
command 0xD060 to 0x5D and requests 48 bytes back. GT911 ACKs the
write (treating it as a register address) and returns 48 bytes of
register data, passing the length check — a false-positive SFA30
detection.

Confirmed via second cold-boot log: after the previous commit moved GT911
to 0x14 in lateInitVariant(), address 0x5D *still* appeared in the scan
because the scanner runs first. The board has no physical SFA30 fitted.

Fix
---
Add the GT911 address-latch reset sequence to earlyInitVariant(), before
Wire is initialised and before the I2C scan runs. Per the datasheet:
drive RST LOW, drive INT HIGH (selects address 0x14 / SLAVE_ADDRESS_H),
hold >100 µs, release RST, wait >5 ms startup. GPIO-only, no Wire
dependency. lateInitVariant() then repeats this sequence internally via
touch.begin(); the double-reset is harmless.

Verified in boot log:
  Before: "SFA30 found at address 0x5d", 5 I2C devices, NACK errors
  After:  no SFA30 entry, 4 I2C devices (TCA9535/PCF8563/BQ27220/BQ25896),
          GT911 found at 0x14 and touch initialised successfully,
          AirQualityTelemetry registers no sensors (correct — no SFA30 present)

* t5s3_epaper: add variant_shutdown() for touch sleep and backlight off

Put GT911 into low-power standby (command 0x05) and drive BOARD_BL_EN
LOW before deep sleep to avoid unnecessary current draw.

* t5s3_epaper: fix touch gesture routing and coordinate mapping

readTouch() now transforms raw GT911 axes to visual-frame coordinates
based on the current display rotation (rotation=3 is the hardware
identity). This ensures TouchScreenBase detects swipe direction
correctly regardless of which rotation the user has selected.

TouchInkHUDBridge dynamically sets joystick.alignment = (4-rotation)%4
on each touch event so that (rotation+alignment)%4==0 always, keeping
nav calls pass-through without remapping.

nicheGraphics.h now calls loadSettings() first so that rotation is
persisted across reboots. rotation=3 and other first-boot defaults are
only applied when tips.firstBoot is set. alignment is recomputed from
the loaded rotation on every boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix GT911 sleep timing via notifyDeepSleep observer

touch.sleep() was called from variant_shutdown(), which runs inside
cpuDeepSleep() — after Wire.end() had already torn down the I2C bus in
doDeepSleep(). This caused Wire NULL TX buffer errors and left the GT911
awake during deep sleep.

Register a CallbackObserver on notifyDeepSleep, which fires before
Wire.end(), so the I2C command reaches the chip while the bus is live.
Pattern matches LatchingBacklight and other NicheGraphics components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix touch nav and applet defaults in nicheGraphics

Enable joystick mode post-begin so menu scroll and swipe-up/down
gestures are not silently dropped by the joystick.enabled gate in
Events.cpp. Activate DMs and Channel 0/1 applets with correct
autoshow defaults matching the mini-epaper-s3 reference pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update nicheGraphics.h

* t5s3_epaper: fix ED047TC1 driver docs and remove spurious beginPolling

Addressing PR review comments:

Remove beginPolling(1, 0) after the blocking FastEPD update — it
incorrectly set updateRunning=true for one loop cycle after the
hardware was already done, causing busy() to briefly return true.
Since isUpdateDone() always returns true, no polling is needed.

Also fix stale comments: safe-area buffer size was 944×532, now
944×523; V_OFFSET_ROWS didn't exist, replaced with the actual
V_OFFSET_TOP=9 / V_OFFSET_BOTTOM=8 constant names.

* t5s3_epaper: clean up applet addition formatting in setupNicheGraphics

* t5s3_epaper: guard ED047TC1.cpp against non-T5S3 InkHUD builds

The InkHUD base config pulls in all of src/graphics/niche/ so every
InkHUD device compiled ED047TC1.cpp, triggering the #error on line 48
for boards that define neither T5_S3_EPAPER_PRO_V1 nor V2.

Wrap the file body with #ifdef T5_S3_EPAPER_PRO so it is only compiled
for T5S3 targets. The #error is preserved inside the guard to catch
future hardware revisions that forget to update the driver.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-04-21 15:35:02 -05:00
Ben MeadorsandCopilot 68383c8bd5 Add encryption overview to agent instructions in AGENTS.md (#10207)
* Add encryption overview to agent instructions in AGENTS.md

* Update AGENTS.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Clarify nonce and wire overhead details in encryption section of copilot instructions

* Enhance encryption documentation in copilot instructions and agents guide for clarity on key management and reset behaviors

* Update .github/copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix botched merge conflict resolution

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-21 10:49:13 -05:00
TomandBen Meadors 76dea77929 Add authoring guide for native unit tests in README.md (#10201)
* Add authoring guide for native unit tests in README.md

* Enhance documentation for agent tooling and native unit tests in README and related files

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-21 10:49:08 -05:00
Clive Blackledge d7ba178bf1 Fix: prompt markdownlint md040 fix for new prompts. (#10199)
* Add ESP32 Power Management lessons learned document

Documents our experimentation with ESP-IDF DFS and why it doesn't
work well for Meshtastic (RTOS locks, BLE locks, USB issues).

Proposes simpler alternative: manual setCpuFrequencyMhz() control
with explicit triggers for when to go fast vs slow.

* docs(prompts): fix markdown fence language tags

* docs: remove ESP32 power management notes
2026-04-21 10:49:01 -05:00
Jennifer Sanchez 5f836cdf3b Added support for Spreading Factors 5 and 6 on compatible radios (#10160) 2026-04-21 10:48:33 -05:00
HarukiToreda 945f4780ea BaseUI: Nodelist screen/favorite screen cleanup (#10197)
* nodelist screen cleanup

* Update UIRenderer.cpp

* Update src/graphics/draw/UIRenderer.cpp

* removed brackets from hop and made signal mutually exclusive
2026-04-21 10:31:29 -05:00
github-actions[bot]andvidplace7 a8a5315460 Upgrade trunk (#10221)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-21 17:14:12 +02:00
github-actions[bot]andcaveman99 0e38a15d46 Update protobufs (#10223)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-04-21 17:13:55 +02:00
Jonathan Bennett d8e4389da2 No longer need undefines, thanks to #10179 (#10180) 2026-04-21 09:57:48 -05:00
Jonathan Bennett 25febfdeee More cleanly remove LED_BUILTIN (#10179)
* Test PR to remove LED_BUILTIN 

Comment out the LED_BUILTIN definition in platformio.ini

* Add LED_BUILTIN definition to nrf52840.ini
2026-04-21 09:57:41 -05:00
Ruledo f5be09c123 Add Luckfox Pico Max Waveshare Pico LoRa config (#10175)
Add a meshtasticd config for the Luckfox Pico Max with the Waveshare Pico LoRa SX1262 TCXO HAT.

Tested on hardware with successful SX1262 init, broadcast, and direct messaging.
2026-04-21 09:57:33 -05:00
23321c4588 Reduce key duplication by enabling hardware RNG (#8803)
* Reduce key duplication by enabling hardware RNG

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Use micros() for worst case random seed for nrf52

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Minor cleanup, remove dead code and clarify comment

* trunk

* Add useRadioEntropy bool, default false.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-21 09:57:06 -05:00
Catalin Patulea e1f5043489 Delete PointerQueue::dequeuePtrFromISR, unused since commit db766f1 (#99). (#10090) 2026-04-21 09:57:00 -05:00
Jaime Roldan 5d9a2564e4 fix(nodedb): force null-terminate name fields in UserLite/User conversions (#8174) (#10218) 2026-04-21 09:52:47 -05:00
Jaime Roldan 63bce1f01a fix(nodedb): force null-terminate name fields in UserLite/User conversions (#8174) (#10218) 2026-04-21 09:52:19 -05:00
nightjoker7 84ce1ea147 SX126x: re-apply 0x8B5 register in resetAGC() to preserve RX sensitivity (#10219)
The CALIBRATE_ALL (0x7F) command inside resetAGC() clears bit 0 of the
undocumented 0x8B5 register. That bit is set once in init() by #9571 and
#9777 to improve SX1262 RX sensitivity, and the AGC-reset path was not
re-applying it. Result: every SX1262 node silently loses the RX
sensitivity patch ~60s after boot and never recovers until reboot.

Empirically confirmed on Heltec Mesh Node T114 (nRF52840 + SX1262):
  - Post-calibration read of 0x8B5 = 0x04 (bit 0 cleared)
  - After re-apply: 0x05 (bit 0 set)
Reproducible every AGC_RESET_INTERVAL_MS tick.

Fix re-applies the register bit alongside the existing post-calibration
re-applies (setDio2AsRfSwitch, setRxBoostedGainMode).
2026-04-21 09:50:35 -05:00
nightjoker7 4090d9f2b3 SX126x: re-apply 0x8B5 register in resetAGC() to preserve RX sensitivity (#10219)
The CALIBRATE_ALL (0x7F) command inside resetAGC() clears bit 0 of the
undocumented 0x8B5 register. That bit is set once in init() by #9571 and
#9777 to improve SX1262 RX sensitivity, and the AGC-reset path was not
re-applying it. Result: every SX1262 node silently loses the RX
sensitivity patch ~60s after boot and never recovers until reboot.

Empirically confirmed on Heltec Mesh Node T114 (nRF52840 + SX1262):
  - Post-calibration read of 0x8B5 = 0x04 (bit 0 cleared)
  - After re-apply: 0x05 (bit 0 set)
Reproducible every AGC_RESET_INTERVAL_MS tick.

Fix re-applies the register bit alongside the existing post-calibration
re-applies (setDio2AsRfSwitch, setRxBoostedGainMode).
2026-04-21 09:50:01 -05:00
renovate[bot] eba74fa6e2 Update GxEPD2 to v1.6.9 (#10212)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 06:08:28 -05:00
github-actions[bot]andvidplace7 8627bce1a1 Upgrade trunk (#10125)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-20 04:55:10 -05:00
Ben MeadorsandCopilot d50caf231b Add encryption overview to agent instructions in AGENTS.md (#10207)
* Add encryption overview to agent instructions in AGENTS.md

* Update AGENTS.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Clarify nonce and wire overhead details in encryption section of copilot instructions

* Enhance encryption documentation in copilot instructions and agents guide for clarity on key management and reset behaviors

* Update .github/copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix botched merge conflict resolution

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-19 16:05:28 -05:00
TomandBen Meadors f396200d38 Add authoring guide for native unit tests in README.md (#10201)
* Add authoring guide for native unit tests in README.md

* Enhance documentation for agent tooling and native unit tests in README and related files

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-19 13:30:50 -05:00
Ben Meadors 6c04c37294 Merge remote-tracking branch 'origin/master' into develop 2026-04-19 12:50:12 -05:00
Ben MeadorsandCopilot Autofix powered by AI de23e5199d Add USB camera and uhubctl support for new test suite. Also included some bug fixes (#10204)
* Add USB camera and uhubctl support for new test suite. Also added some bug fixes

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refactor test messages for clarity and consistency in regex tests

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-19 06:51:41 -05:00
6b15571e14 Add MCP server for interacting with meshtastic devices and testing framework / TUI (#10194)
* Start of MCP server and test suite

* Add MCP server for interacting with meshtastic devices and testing framework / TUI

* Update mcp-server/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix mcp-server review feedback from thread

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control

* Semgrep fixes

* Trunk and semgrep fixes

* optimize pio streaming tee file writes

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* chore: remove redundant log handle assignment

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Consolidate type imports and remove placeholder test files

* Add tests for config persistence and more exchange messages

* Refactor position test to validate on-demand request/reply behavior

* Remove  position request/reply test and update README for telemetry behavior

* Fix transmit history file to get removed on factory reset

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-18 11:29:02 -05:00
Ben Meadors 8fd0a7f283 Merge branch 'master' into master 2026-04-18 11:17:03 -05:00
Ben Meadors e589de2d6e Tronk 2026-04-18 11:12:05 -05:00
Ben MeadorsandCopilot a277108c84 Update src/graphics/EInkDisplay2.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-18 11:11:41 -05:00
Ben MeadorsandCopilot 9e26cc3795 Update src/main.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-18 11:11:41 -05:00
Ben MeadorsandCopilot 84bb909437 Update variants/esp32s3/t-deck-pro-v1_1/platformio.ini
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-18 11:11:41 -05:00
Ben MeadorsandCopilot 2beebea453 Update src/platform/extra_variants/t_deck_pro/variant.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-18 11:11:41 -05:00
Ben MeadorsandCopilot d0cd8ec366 Update variants/esp32s3/t-deck-pro-v1_1/variant.h
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-18 11:11:41 -05:00
lewisxhe d31d0f85fe Added compatibility with LilyGo T-Deck-Pro V1.1 2026-04-18 11:11:41 -05:00
renovate[bot] 2c8dec2fbd Update meshtastic/device-ui digest to 56e1da4 (#10195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 19:48:07 -05:00
Ben Meadors c48b5ab556 Merge branch 'master' into master 2026-04-17 05:22:30 -05:00
Ben MeadorsandCopilot 79e7ed30f1 Update src/graphics/EInkDisplay2.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 07:49:34 -05:00
Ben MeadorsandCopilot d5af07e458 Update src/main.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 07:49:08 -05:00
Ben MeadorsandCopilot 7d957f8c7b Update variants/esp32s3/t-deck-pro-v1_1/platformio.ini
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 07:48:50 -05:00
Ben MeadorsandCopilot 5cae9e0183 Update src/platform/extra_variants/t_deck_pro/variant.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 07:48:35 -05:00
Ben MeadorsandCopilot edf660ccb3 Update variants/esp32s3/t-deck-pro-v1_1/variant.h
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 07:48:19 -05:00
Ben Meadors e7b66281fa Merge branch 'master' into master 2026-04-16 07:07:03 -05:00
lewisxhe 15b474172a Added compatibility with LilyGo T-Deck-Pro V1.1 2026-01-21 17:33:50 +08:00
209 changed files with 16164 additions and 1602 deletions
+12 -5
View File
@@ -24,7 +24,12 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
- `mcp__meshtastic__get_config(section="lora", port=<p>)` — region, preset, channel_num, tx_power, hop_limit.
- Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=<p>, env=<inferred-env>)`, wait 3s, `serial_read(session_id=<s>, max_lines=100)`, `serial_close(session_id=<s>)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*<ROLE>` is set.
4. **Render per-device report** as:
4. **Hub health** (call once, not per-device): `mcp__meshtastic__uhubctl_list()` — enumerates every USB hub the host can see. Note which hubs advertise `ppps=true` and which hub hosts each Meshtastic device (cross-reference by VID). Flag it in the report if:
- No hub advertises PPPS → `tests/recovery/` can't run on this setup; hard-recovery via `uhubctl_cycle` isn't available.
- A Meshtastic device is on a non-PPPS hub → note it; operator may want to move the device to a PPPS hub to unlock auto-recovery.
- `uhubctl_list` raises `ConfigError: uhubctl not found` → just say `uhubctl not installed` in the report; don't treat as a fault.
5. **Render per-device report** as:
```text
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
@@ -33,20 +38,22 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
tx_power : 30 dBm, hop_limit=3
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
primary ch : McpTest
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts
```
Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub), flag it inline with a short `⚠︎ <one-line reason>`.
Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub, device on non-PPPS hub), flag it inline with a short `⚠︎ <one-line reason>`.
5. **Cross-device correlation** (only when >1 device is inspected):
6. **Cross-device correlation** (only when >1 device is inspected):
- Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it.
- Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh)
- Do the primary channel NAMES match? Mismatch = different PSK = no decode.
6. **Suggest next actions only for specific, recognisable failure modes**:
7. **Suggest next actions only for specific, recognisable failure modes**:
- Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path."
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
- Device unreachable → point at touch_1200bps + the CP2102-wedged-driver note in run-tests.sh.
- Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
- CP2102-wedged-driver on macOS → see the note in `run-tests.sh`.
## What NOT to do
+2 -1
View File
@@ -44,7 +44,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
- **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup.
- **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer.
- **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. For a device that's wedged past `touch_1200bps`, the next escalation is `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed).
- **Device went dark mid-run** → fails from some attempt onward, never recovers, firmware log stops arriving. Almost always hardware: a Guru crash + frozen CDC. Hard-power-cycle via `uhubctl_cycle(role=..., confirm=True)` before the next iteration; if that also fails, escalate to replug.
- **Genuinely unknown** → say so; don't invent a root cause.
7. **Report back** with:
+9 -4
View File
@@ -19,16 +19,21 @@ Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn
2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped.
3. **On pass**: one-line summary of the form `N passed, M skipped in <duration>`. Don't enumerate the 52 test names — the user can read those. Do mention if any test was SKIPPED for a NON-placeholder reason (e.g. "role not present on hub" is worth flagging).
3. **On pass**: one-line summary of the form `N passed, M skipped in <duration>`. Don't enumerate the test names — the user can read those. Do mention any SKIPPED tests and name the cause:
- `"role not present on hub"` → device unplugged; operator knows to reconnect.
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped because the macro isn't in firmware yet; suggest `--force-bake`.
- `"uhubctl not installed"` → tests/recovery + peer-offline skipped; suggest `brew install uhubctl` / `apt install uhubctl`.
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the hub doesn't support per-port power; the tier will never run on that setup.
- `"opencv-python-headless is not installed"` → tests/ui auto-deselected by run-tests.sh; suggest `pip install -e 'mcp-server/.[ui]'`.
4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`).
4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). For UI-tier failures also glance at `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` — it records each step's frame + OCR.
5. **Classify the failure** as one of:
- **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro <test_node_id>` to confirm.
- **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery (replug USB, `touch_1200bps`, check `git status userPrefs.jsonc`).
- **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery in escalation order: (a) replug USB, (b) `touch_1200bps(port=...)` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role="nrf52", confirm=True)` when a device is fully wedged past DFU (needs `uhubctl` installed — `baked_single`'s auto-recovery hook does this once automatically). Also check `git status userPrefs.jsonc`.
- **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible.
6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, or USB replug, \_describe what to do* — don't execute. The operator decides.
6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or USB replug, \_describe what to do* — don't execute. The operator decides.
## Arguments handling
+4 -4
View File
@@ -75,11 +75,11 @@ body:
- type: checkboxes
id: mui
attributes:
label: Is this bug report about any UI component firmware like InkHUD or Meshtatic UI (MUI)?
label: Is this bug report about any UI (https://meshtastic.org/docs/configuration/device-uis/) component firmware?
options:
- label: Meshtastic UI aka MUI colorTFT
- label: InkHUD ePaper
- label: OLED slide UI on any display
- label: Meshtastic UI aka MUI
- label: InkHUD
- label: BaseUI
- type: input
id: version
+109 -18
View File
@@ -70,6 +70,70 @@ PKI (Public Key Infrastructure) messages have special handling:
- Accepted on a special "PKI" channel
- Allow encrypted DMs between nodes that discovered each other on downlink-enabled channels
## Encryption & Key Management
Meshtastic packets on the air are typically encrypted one of two ways: the **per-channel symmetric** layer (AES-CTR with a shared PSK) for broadcasts and channel traffic, and the **per-peer PKI** layer (X25519 ECDH → AES-256-CCM) for direct messages and remote admin. A channel with a 0-byte PSK (or Ham mode, which wipes PSKs) transmits cleartext — see the size table below. Both are implemented in `src/mesh/CryptoEngine.cpp`; the send/receive dispatch lives in `src/mesh/Router.cpp`; admin authorization lives in `src/modules/AdminModule.cpp`.
### High-level model
- **Channels** are symmetric rooms: anyone with the PSK can read any message on the channel. Channel 0 is the "primary" channel and ships with the short-form default PSK on factory devices, forming the public mesh most users join. (The LoRa modem preset `LONG_FAST` lives on `config.lora.modem_preset` and is an independent field — don't conflate "channel 0 default PSK" with the modem preset name.)
- **DMs** addressed to a single node require PKI so that other holders of the channel PSK can't read them. Outside Ham mode, Meshtastic does not fall back to channel-symmetric encryption when the destination public key is unknown.
- **Remote admin** is a DM carrying an `AdminMessage`. The receiver only acts on it if the sender's public key is on its allowlist (`config.security.admin_key[0..2]`).
- **Ham mode** (`owner.is_licensed=true`, where `owner` is the local `meshtastic_User` record) disables PKI entirely and sends cleartext — FCC Part 97 prohibits encryption on amateur bands.
- **No ratchet, no session.** Every packet is encrypted from scratch — a stateless design that matches the high-loss, store-and-forward nature of LoRa.
### Symmetric channel encryption (AES-CTR)
`CryptoEngine::encryptPacket` / `decrypt` / `encryptAESCtr` in `src/mesh/CryptoEngine.cpp`.
- **Cipher**: AES-CTR, AES-128 or AES-256 depending on key length. Same routine in both directions (CTR is a stream cipher, so encrypt == decrypt).
- **Key**: `ChannelSettings.psk` bytes. Size semantics:
- **0 bytes** → no encryption, cleartext on the air
- **1 byte** → short-form index into the well-known `defaultpsk[]` in `src/mesh/Channels.h`. Index 0 = cleartext; 1 = defaultpsk unchanged; 2..255 = defaultpsk with its last byte incremented by (index − 1). This is what the CLI's `--ch-set psk default` produces.
- **16 bytes** → raw AES-128 key
- **32 bytes** → raw AES-256 key
- **2..15 bytes** → zero-padded to 16 and used as AES-128 (with a warn log); **17..31 bytes** → zero-padded to 32 and used as AES-256 (with a warn log). Defensive fallback for malformed PSK input, not something to rely on.
- **Nonce (128 bit)**: `packet_id` (u64 LE) ‖ `from_node` (u32 LE) ‖ `block_counter` (u32, starts at 0). Built in `CryptoEngine::initNonce`.
- **No AEAD**: channel packets carry no MAC, so the channel-hash byte is not an integrity or authenticity check. `Channels::getHash` is a 1-byte XOR-derived hint over the channel name bytes and PSK bytes that helps receivers pick a candidate channel/PSK for decryption. Because it is only a small hint and collisions are easy to find, it should be described purely as a PSK-selection aid, not as a security filter an attacker cannot bypass.
- **Channel 0 is special in one way only**: it's the channel the Router attempts PKI decryption on before falling through to AES-CTR. Non-zero channels always go straight to AES-CTR.
### PKI encryption for DMs (X25519 ECDH + AES-256-CCM)
`CryptoEngine::encryptCurve25519` / `decryptCurve25519` in `src/mesh/CryptoEngine.cpp`.
- **Keypair**: Curve25519 (aka X25519), 32-byte public + 32-byte private. Stored in `config.security.public_key` / `private_key`; the public half is mirrored into `owner.public_key` so it rides along in NodeInfo broadcasts and propagates through the mesh like any other identity field.
- **Key generation** (`generateKeyPair`): stirs `HardwareRNG::fill()` (64 B from platform TRNG when available), the 16-byte `myNodeInfo.device_id`, and a call to `random()` into the rweather/Crypto library's software RNG, then `Curve25519::dh1`. `regeneratePublicKey` recomputes the public half from a known private (used when restoring from backup).
- **Keygen entry points**: at boot, `NodeDB` calls `generateKeyPair` (or `regeneratePublicKey` when a stored private key is present and passes a low-entropy check) **directly** when `!owner.is_licensed` and `config.lora.region != UNSET`. `ensurePkiKeys` wraps the same logic for runtime/admin flows — it's the path `AdminModule::handleSetConfig` runs when first assigning a valid region or when security config is written; **do not assume it's the universal boot-time gate**, because the NodeDB path bypasses it.
- **Handshake**: `Curve25519::dh2(local_private, remote_public) → 32-byte shared secret → SHA-256 → 32-byte AES-256 key`. Recomputed per packet. The SHA-256 step is effectively a KDF over the raw ECDH output.
- **Cipher**: AES-256-CCM via `aes_ccm_ae` / `aes_ccm_ad` (`src/mesh/aes-ccm.cpp`). MAC length (the `M` parameter) is **8 bytes**. No AAD — the MAC covers ciphertext only.
- **Nonce (13 bytes / 104 bit)**: `aes_ccm_ae`/`aes_ccm_ad` use a 13-byte CCM nonce (`L = 2` is hardcoded in `src/mesh/aes-ccm.cpp`), not a 16-byte nonce. For PKI packets, `CryptoEngine::initNonce(fromNode, packetNum, extraNonce)` starts from the usual packet-derived nonce material, then overwrites nonce bytes `4..7` with a fresh 32-bit `extraNonce = random()`. The effective nonce bytes are therefore: bytes `0..3` = `packet_id`, bytes `4..7` = transmitted `extraNonce`, bytes `8..11` = `from_node`, byte `12` = `0x00`. The receiver reconstructs the same 13-byte nonce from the packet metadata plus the appended `extraNonce`.
- **Wire overhead**: 12 bytes appended to the ciphertext = 8-byte MAC ‖ 4-byte extraNonce. Defined as `MESHTASTIC_PKC_OVERHEAD = 12` in `src/mesh/RadioInterface.h`. Only the 4-byte `extraNonce` is sent; the rest of the 13-byte CCM nonce is reconstructed from packet fields as described above. The Router's send path checks this overhead against `MAX_LORA_PAYLOAD_LEN` before committing to PKI.
- **Send selection** (`Router::send`): the sender enters the PKI path when **all** hold — we're the originator AND not Ham mode AND not Portduino simradio AND not on the `serial`/`gpio` channels (unless the packet is already marked `pki_encrypted`) AND `config.security.private_key.size == 32` AND destination is a single node (not broadcast) AND the portnum isn't infrastructure. `TRACEROUTE_APP`, `NODEINFO_APP`, `ROUTING_APP`, and `POSITION_APP` are routed through channel encryption even when DMed (these need to be readable by relaying peers). Once on the PKI path, if the destination's public key isn't in our NodeDB the send **fails** with `PKI_SEND_FAIL_PUBLIC_KEY` — it does not silently fall back to channel encryption. If the client explicitly set `pki_encrypted=true` and any condition blocks PKI, the send fails with `PKI_FAILED`.
- **Receive selection** (`Router::perhapsDecode`): try PKI decrypt first when `channel == 0` AND `isToUs(p)` AND not broadcast AND both peers have public keys in NodeDB AND `rawSize > MESHTASTIC_PKC_OVERHEAD`. On success the packet gets `pki_encrypted=true` stamped and the sender's public key copied into `p->public_key` for downstream authorization.
### Remote admin authorization
Implemented in `src/modules/AdminModule.cpp` → `handleReceivedProtobuf`. The authorization check runs in this order:
1. **Response messages** — if `messageIsResponse(r)` is true (the payload is a response to one of our earlier admin requests), it's accepted without any further check. The in-file comment flags this as a known-untightened gap: a stricter implementation would remember which `public_key` we last queried and reject responses that don't match.
2. **Local admin** — `mp.from == 0` (phone app over BLE, serial CLI, internal module); never travels over the air. **Rejected** if `config.security.is_managed` is true, because managed devices expect admin to arrive over the air through an authorized remote path.
3. **Legacy admin channel (deprecated)** — the packet arrived on a channel named literally `"admin"`. Gated by `config.security.admin_channel_enabled`; returns `NOT_AUTHORIZED` if the flag is false. Kept for backward compatibility; new deployments should use PKI admin.
4. **PKI admin (preferred for remote)** — `mp.pki_encrypted == true` AND `mp.public_key` matches one of `config.security.admin_key[0..2]` (up to three authorized 32-byte Curve25519 public keys, typically copied from the admin node's own `user.public_key`).
5. **Fallthrough** → `NOT_AUTHORIZED`.
On top of authorization, any remote admin message that **mutates** state (not a request, not a response) also has to pass a session-key check (`checkPassKey`): the client must first pull a fresh 8-byte `session_passkey` via `get_admin_session_key_request`, then echo that passkey back in the mutating message. The device rotates the passkey after 150 s and rejects values older than 300 s — a narrow anti-replay window on top of the PKI layer.
`config.security.is_managed = true` disables **local** admin writes (`mp.from == 0` is rejected). It does not by itself force every admin action through PKI — the legacy `"admin"` channel still authorizes remote admin when `config.security.admin_channel_enabled == true`. The AdminModule refuses to persist `is_managed=true` unless at least one `admin_key` is populated — a deliberate guard against operators locking themselves out.
### Key-rotation hazards (actions that invalidate peers)
- **`factory_reset_device`** (the "full" variant, calls `NodeDB::factoryReset(eraseBleBonds=true)`) → **wipes** the X25519 private key; a fresh keypair is generated on the next region-set. Every existing peer holds the old public key, so DMs to this node silently fail PKI decrypt until every peer re-exchanges NodeInfo.
- **`factory_reset_config`** (the "partial" variant, calls `NodeDB::factoryReset()` with `eraseBleBonds=false`) → **preserves** the X25519 private key in `installDefaultConfig(preserveKey=true)`; the public key is zeroed and gets rebuilt from the preserved private key on the next boot via the NodeDB path's `regeneratePublicKey` call. Identity is preserved and the mesh does not need to re-exchange keys.
- **`region=UNSET → valid region`** → `ensurePkiKeys` runs inside the same `handleSetConfig` path; missing keys get generated at that moment.
- **Ham mode transitions** — entering Ham mode (`user.is_licensed=true`) runs `Channels::ensureLicensedOperation`, which **wipes every channel PSK** (all traffic becomes cleartext) and disables the legacy admin channel. The X25519 private key is preserved on the device but not used because `Router::send` skips PKI when `owner.is_licensed` is true. Leaving Ham mode re-enables PKI with the preserved keypair but does not restore the wiped channel PSKs — the operator has to re-set them.
- **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.
## Project Structure
```
@@ -80,7 +144,7 @@ firmware/
│ │ ├── NodeDB.* # Node database management
│ │ ├── Router.* # Packet routing
│ │ ├── Channels.* # Channel management
│ │ ├── CryptoEngine.* # AES-CCM encryption
│ │ ├── CryptoEngine.* # AES-CTR (channels) + X25519 ECDH→AES-256-CCM (PKI for DMs/admin)
│ │ ├── *Interface.* # Radio interface implementations
│ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI)
│ │ ├── http/ # HTTP server (WebServer, ContentHandler)
@@ -296,6 +360,23 @@ Key defines in variant.h:
## Build System
## Agent Tooling Baseline
Mirror counterpart: `AGENTS.md` under **Agent Tooling Baseline**.
To reduce avoidable agent mistakes, assume these tools are available (or install them before significant repo work):
- **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs`
- **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing
- **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`)
- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts)
Fallback expectations for agents:
- If `rg` is unavailable, use `find` + `grep` instead of failing.
- For native tests on hosts without Linux deps, prefer `./bin/test-native-docker.sh`.
- The simulator helper script is `./bin/test-simulator.sh`.
Uses **PlatformIO** with custom scripts:
- `bin/platformio-pre.py` - Pre-build script
@@ -448,6 +529,8 @@ Run with: `pio test -e native`
Simulation testing: `bin/test-simulator.sh`
Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist).
### Hardware-in-the-loop tests (`mcp-server/tests/`)
Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules.
@@ -474,7 +557,7 @@ The repo registers the server via `.mcp.json` at the repo root — Claude Code p
**One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port.
### MCP tool surface (~32 tools)
### MCP tool surface (43 tools)
Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here.
@@ -482,11 +565,13 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v
- **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps`
- **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close`
- **Device reads**: `device_info`, `list_nodes`
- **Device writes** (all require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api`
- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset`
- **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile`
- **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw`
- **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` + `_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` — grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured.
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset` and `erase_and_flash`.
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`.
### Hardware test suite (`mcp-server/run-tests.sh`)
@@ -494,14 +579,16 @@ The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf5
Suite tiers (collected + run in this order via `pytest_collection_modifyitems`):
1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile). No hardware.
1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile, uhubctl parser). No hardware.
2. `tests/test_00_bake.py` — flashes each detected device with current `userPrefs.jsonc` merged with the session's test profile. Has its own skip-if-already-baked check comparing region + primary channel to the session profile; skips cheaply on warm devices.
3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`.
3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. Includes `test_peer_offline_recovery` which uses uhubctl to physically power off one peer mid-conversation (requires uhubctl; skips without).
4. `tests/telemetry/` — `DEVICE_METRICS_APP` broadcast timing.
5. `tests/monitor/` — boot-log panic check.
6. `tests/fleet/` — PSK seed session isolation.
7. `tests/admin/` — channel URL roundtrip, owner persistence across reboot.
8. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset.
6. `tests/recovery/` — `uhubctl` power-cycle round-trip + NVS persistence across hard reset. Requires `uhubctl` installed and a PPPS-capable hub; entire tier auto-skips otherwise.
7. `tests/ui/` — input-broker-driven screen navigation with camera + OCR evidence.
8. `tests/fleet/` — PSK seed session isolation.
9. `tests/admin/` — channel URL roundtrip, owner persistence across reboot.
10. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset.
Invocation patterns:
@@ -586,15 +673,19 @@ If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` fl
### Recovery playbooks
| Symptom | First check | Fix |
| ---------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. |
| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. |
| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. |
| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. |
| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. |
| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. |
| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. |
| Symptom | First check | Fix |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. |
| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. |
| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. |
| Device fully wedged (Guru Meditation, frozen CDC, no DFU) | `list_devices` shows the VID but every admin call times out | `uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles the port via USB hub PPPS. `baked_single`'s auto-recovery hook does this once automatically if uhubctl is installed. Falls back to physical replug if no PPPS hub. |
| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. |
| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. |
| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. |
| Entire `tests/recovery/` tier skipped | `command -v uhubctl` | Expected if `uhubctl` isn't on PATH. Install via `brew install uhubctl` (macOS) or `apt install uhubctl` (Debian/Ubuntu). Also skips if no hub advertises PPPS. |
| Entire `tests/ui/` tier skipped ("firmware not baked with USERPREFS_UI_TEST_LOG") | reportlog.jsonl for the skip reason | Re-run with `--force-bake` so the UI-log macro gets compiled into the fresh firmware. First run after the Round-3 landing always re-bakes. |
| `tests/ui/` runs but captures are all 1×1 black PNGs | `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3` | Env var not set → `NullBackend`. Point a USB webcam at the heltec-v3 OLED and set the device index; `.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"` discovers it. |
| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. |
### Never do these without asking
+12 -5
View File
@@ -26,7 +26,12 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
- `get_config(section="lora", port=<p>)` → region, preset, channel_num, tx_power, hop_limit
- If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=<p>, env=<inferred>)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_<ROLE>` env var overrides it.
4. **Render per-device report** as a compact block:
4. **Hub health** (call once, not per-device): `uhubctl_list()` — enumerates every USB hub the host sees. Cross-reference each Meshtastic device's VID to find which hub + port it's on. Flag in the report if:
- No hub advertises `ppps=true` → `tests/recovery/` can't run; hard-recovery via `uhubctl_cycle` isn't available.
- A Meshtastic device is on a non-PPPS hub → note it; moving to a PPPS hub unlocks auto-recovery.
- `uhubctl_list` raises `ConfigError: uhubctl not found` → report as "uhubctl not installed"; don't treat as a device fault.
5. **Render per-device report** as a compact block:
```text
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
@@ -35,20 +40,22 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
tx_power : 30 dBm, hop_limit=3
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
primary ch : McpTest
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
firmware : no panics in last 3s
```
Flag abnormalities inline with `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, etc.
Flag abnormalities inline with `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc.
5. **Cross-device correlation** (when >1 device selected):
6. **Cross-device correlation** (when >1 device selected):
- Do both see each other in `nodesByNum`?
- Do `region`, `channel_num`, `modem_preset` match across devices?
- Do the primary channel names match? (Different name → different PSK → no decode.)
6. **Suggest next steps only for recognizable failure modes**, never speculatively:
7. **Suggest next steps only for recognizable failure modes**, never speculatively:
- Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` — the test's retry+nodeinfo-ping heals this."
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
- Device unreachable → refer operator to the touch_1200bps + CP2102-wedged-driver notes in `run-tests.sh`.
- Device unreachable, DFU reachable → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds and the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
- CP2102-wedged-driver on macOS → see `run-tests.sh` notes.
## Hard constraints
+2 -1
View File
@@ -46,7 +46,8 @@ Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one
- **LoRa airtime collision** — pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body.
- **PKI key staleness** — first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix.
- **NodeInfo cooldown** — `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup.
- **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc.
- **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. For a device wedged past `touch_1200bps`, recommend `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed).
- **Device went dark mid-run** — fails from some iteration onward and never recovers; firmware log stops arriving. Almost always a Guru crash with frozen CDC. Recommend `uhubctl_cycle` before the next iteration; escalate to replug if that also fails.
- **Unknown** — say so. Don't invent a root cause.
7. **Report back** with:
+9 -3
View File
@@ -21,19 +21,25 @@ Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md
2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only — call that out explicitly so the operator knows hardware tiers were skipped.
3. **On pass**: one-line summary like `N passed, M skipped in <duration>`. Don't enumerate test names. DO mention any non-placeholder SKIPs (things like "role not present on hub") because they indicate missing hardware or setup issues.
3. **On pass**: one-line summary like `N passed, M skipped in <duration>`. Don't enumerate test names. DO mention any non-placeholder SKIPs and name the cause:
- `"role not present on hub"` → device unplugged; operator should reconnect.
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped; the UI-log compile macro isn't in the baked firmware. Suggest `--force-bake`.
- `"uhubctl not installed"` → tests/recovery + `test_peer_offline_recovery` skipped. Suggest `brew install uhubctl` / `apt install uhubctl`.
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the attached hub doesn't support per-port power switching; won't run on that setup.
- `"opencv-python-headless is not installed"` → tests/ui auto-deselected by `run-tests.sh`. Suggest `pip install -e 'mcp-server/.[ui]'`.
4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise:
- test name
- one-line assertion message
- the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`)
- for UI-tier failures also check `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` (per-step frame + OCR)
5. **Classify each failure** as one of:
- **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro <test-id>` to confirm.
- **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest specific recovery (USB replug, `touch_1200bps`, `git status userPrefs.jsonc`).
- **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest recovery in escalation order: (a) replug USB, (b) `touch_1200bps` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role=..., confirm=True)` for a device wedged past DFU (needs `uhubctl` installed; `baked_single` does this once automatically when available). Also check `git status userPrefs.jsonc`.
- **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible.
6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval.
6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval.
## Arguments convention
+7 -1
View File
@@ -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
+7 -7
View File
@@ -8,18 +8,18 @@ plugins:
uri: https://github.com/trunk-io/plugins
lint:
enabled:
- checkov@3.2.517
- renovate@43.110.9
- prettier@3.8.1
- trufflehog@3.94.3
- checkov@3.2.524
- renovate@43.141.0
- prettier@3.8.3
- trufflehog@3.95.2
- yamllint@1.38.0
- bandit@1.9.4
- trivy@0.69.3
- trivy@0.70.0
- taplo@0.10.0
- ruff@0.15.9
- ruff@0.15.11
- isort@8.0.1
- markdownlint@0.48.0
- oxipng@10.1.0
- oxipng@10.1.1
- svgo@4.0.1
- actionlint@1.7.12
- flake8@7.3.0
+41 -15
View File
@@ -48,6 +48,15 @@ Three test-and-diagnose workflows exist as slash commands:
Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index.
## Encryption at a glance
Two layers, both in `src/mesh/CryptoEngine.cpp`:
- **Channel (symmetric)** — **AES-CTR** with a channel-wide PSK (AES-128 or AES-256). Nonce = packet_id ‖ from_node ‖ block_counter. No AEAD; integrity is soft (channel-hash filter). The well-known default PSK lives in `src/mesh/Channels.h`; a 1-byte PSK is a short-form index into it.
- **Per-peer PKI** — **X25519 ECDH** (Curve25519, 32-byte keys) → SHA-256 → **AES-256-CCM** with an 8-byte MAC. Fresh 32-bit `extraNonce` per packet, sent in the clear alongside the MAC. 12-byte wire overhead (`MESHTASTIC_PKC_OVERHEAD`). Used for DMs. Also used for remote admin (`src/modules/AdminModule.cpp`), where AdminMessage authorization is gated by `config.security.admin_key[0..2]`. Disabled entirely in Ham mode (`user.is_licensed=true`).
Key rotation to never trigger casually: only the **full** factory reset (`factory_reset_device`, `eraseBleBonds=true`) wipes `security.private_key` and regenerates the keypair — every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The **partial** config reset (`factory_reset_config`) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking `security.private_key` via admin also triggers regen. See the **Encryption & Key Management** section of `.github/copilot-instructions.md` for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, `is_managed` scope, key-rotation hazards).
## House rules
- **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes.
@@ -89,25 +98,42 @@ Sequence these; don't parallelize on the same port.
## Where to look
| Path | What's there |
| --------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) |
| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI |
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `fleet/`, `admin/`, `provisioning/` |
| `.claude/commands/` | Claude Code slash command bodies |
| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) |
| `.github/copilot-instructions.md` | **Primary agent instructions — read this** |
| `.github/workflows/` | CI pipelines |
| `.mcp.json` | MCP server registration for Claude Code |
| Path | What's there |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) |
| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI |
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` |
| `.claude/commands/` | Claude Code slash command bodies |
| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) |
| `.github/copilot-instructions.md` | **Primary agent instructions — read this** |
| `.github/workflows/` | CI pipelines |
| `.mcp.json` | MCP server registration for Claude Code |
## Recovery one-liners
- **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`.
- **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs.
- **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access.
- **Port busy?** `lsof <port>` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it.
- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned.
## Environment variables (test harness)
| Var | Purpose |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `MESHTASTIC_MCP_ENV_<ROLE>` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. |
| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp-<user>-<host>`. |
| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. |
| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. |
| `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. |
| `MESHTASTIC_UHUBCTL_PORT_<ROLE>` | Pin a role to a specific hub port number. Required alongside `LOCATION_<ROLE>`. |
| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). |
| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. |
| `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). |
| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). |
| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. |
+2 -1
View File
@@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION
pio pkg install --environment "$PIO_ENV" || platformioFailed
pio run --environment "$PIO_ENV" || platformioFailed
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)"
os_name=$(uname -s | tr '[:upper:]' '[:lower:]')
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)"
cp bin/native-install.* $OUTDIR/
+118
View File
@@ -0,0 +1,118 @@
#!/bin/bash
# Script to show commits in develop that are not in master
# with their associated PR info and commit hashes
#
# Usage:
# ./show-unmerged-prs.sh # Show all unmerged commits
# ./show-unmerged-prs.sh --bugfix # Show only bugfix-labeled PRs
set -e
REPO="firmware"
OWNER="meshtastic"
BASE_BRANCH="master"
HEAD_BRANCH="develop"
LIMIT=100
FILTER_LABEL=""
# Parse arguments
for arg in "$@"; do
case $arg in
--bugfix)
FILTER_LABEL="bugfix"
shift
;;
--feature)
FILTER_LABEL="feature"
shift
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --bugfix Show only PRs labeled with 'bugfix'"
echo " --feature Show only PRs labeled with 'feature'"
echo " --help Show this help message"
exit 0
;;
esac
done
if [ -n "$FILTER_LABEL" ]; then
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH (filtered by label: $FILTER_LABEL)..."
else
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH..."
fi
echo ""
# Check if gh CLI is available
if ! command -v gh &> /dev/null; then
echo "ERROR: GitHub CLI (gh) not found. Please install it first."
echo "Visit: https://cli.github.com/"
exit 1
fi
# Get commits in develop that are not in master
# For each commit, try to find associated PR
git fetch origin develop master 2>/dev/null || true
# Use git to get the list of commits
commits=$(git log --pretty=format:"%H|%s" origin/master..origin/develop | head -n $LIMIT)
count=0
displayed=0
echo "Commits in $HEAD_BRANCH not in $BASE_BRANCH:"
echo "=============================================="
echo ""
while IFS='|' read -r hash subject; do
((count++))
# Try to find the PR for this commit
# Extract PR number, title, description, and labels
pr_response=$(gh api -X GET "/repos/$OWNER/$REPO/commits/$hash/pulls" \
-H "Accept: application/vnd.github.v3+json" 2>/dev/null | \
jq -r '.[0] | "\(.number)|\(.title)|\(.body // "No description")|\(.labels | map(.name) | join(","))"' 2>/dev/null || echo "||||")
if [ -z "$pr_response" ] || [ "$pr_response" = "||||" ]; then
# If no PR found, skip if filter is active, otherwise show the commit
if [ -z "$FILTER_LABEL" ]; then
((displayed++))
echo "[$displayed] Commit: $hash"
echo " Subject: $subject"
echo " PR: Not found in GitHub"
echo ""
fi
else
IFS='|' read -r pr_num pr_title pr_desc pr_labels <<< "$pr_response"
# Check if filter matches
if [ -n "$FILTER_LABEL" ]; then
# Only show if the label is in the labels list
if ! echo "$pr_labels" | grep -q "$FILTER_LABEL"; then
continue
fi
fi
((displayed++))
echo "[$displayed] PR #$pr_num - $pr_title"
echo " Commit: $hash"
if [ -n "$pr_desc" ] && [ "$pr_desc" != "No description" ]; then
# Truncate description to 200 chars
desc_short="${pr_desc:0:200}"
[ ${#pr_desc} -gt 200 ] && desc_short+="..."
echo " Description: $desc_short"
fi
if [ -n "$pr_labels" ] && [ "$pr_labels" != "" ]; then
echo " Labels: $pr_labels"
fi
echo ""
fi
done <<< "$commits"
echo ""
if [ -n "$FILTER_LABEL" ]; then
echo "Done. Showing $displayed PRs with label '$FILTER_LABEL' from $displayed commits checked."
else
echo "Done. Showing $displayed commits from $HEAD_BRANCH not in $BASE_BRANCH."
fi
+3
View File
@@ -24,3 +24,6 @@ tests/.tui-runs
tests/.history/
# Reproducer bundles (TUI `x` export on failed tests).
tests/reproducers/
# UI-tier camera captures + per-test transcripts. Regenerated every run;
# left on disk for human review between runs.
tests/ui_captures/
+86 -2
View File
@@ -61,7 +61,7 @@ Replace `<firmware-repo>` with the absolute path, e.g. `/Users/you/GitHub/firmwa
Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
## Tools (38)
## Tools (43)
### Discovery & metadata
@@ -130,6 +130,34 @@ _The tool tables below document 38 currently registered MCP server tools._
| `picotool_load` | Load a UF2 |
| `picotool_raw` | Pass-through |
### USB power control (uhubctl)
| Tool | What it does |
| --------------- | ----------------------------------------------------------- |
| `uhubctl_list` | Enumerate USB hubs + attached-device VID/PID (read-only) |
| `uhubctl_power` | Drive a hub port `on` or `off`; `off` requires confirm=True |
| `uhubctl_cycle` | Off → wait `delay_s` → on; confirm=True required |
Target a port by explicit `(location, port)` (raw uhubctl syntax like
`location="1-1.3", port=2`) or by `role` (`"nrf52"`, `"esp32s3"`). Role
lookup checks `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
`MESHTASTIC_UHUBCTL_PORT_<ROLE>` env vars first, then auto-detects via VID
against `uhubctl`'s output.
Requires [`uhubctl`](https://github.com/mvp/uhubctl) on PATH:
```bash
brew install uhubctl # macOS
apt install uhubctl # Debian/Ubuntu
```
Modern macOS + PPPS-capable hubs generally work without root. On Linux
without udev rules, or on old macOS with driver quirks, you may need
`sudo`. If uhubctl returns a permission error the MCP tool raises a
clear `UhubctlError` pointing at the
[udev-rules / sudo fallback](https://github.com/mvp/uhubctl#linux-usb-permissions)
rather than auto-`sudo`'ing mid-run.
## Safety
- **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude.
@@ -182,10 +210,22 @@ in the pre-flight header.
- **`unit`** — pure Python, no hardware. boards / PIO wrapper /
userPrefs-parse / testing-profile fixtures.
- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK,
traceroute, bidirectional. Parametrized over both directions.
traceroute, bidirectional. Parametrized over both directions. Includes
`test_peer_offline_recovery` which uses uhubctl to power-cycle one peer
mid-conversation and verifies the mesh recovers (skips without uhubctl).
- **`telemetry`** — periodic telemetry broadcast + on-demand request/reply
(`TELEMETRY_APP` with `wantResponse=True`).
- **`monitor`** — boot log has no panic markers within 60 s of reboot.
- **`recovery`** — `uhubctl` power-cycle round-trip: verifies the hub port
can be toggled off/on, the device re-enumerates with the same
`my_node_num`, and NVS-resident config (region, channel, modem preset)
survives a hard reset. Requires `uhubctl` on PATH; skips cleanly otherwise.
- **`ui`** — input-broker-driven screen navigation (`AdminMessage.send_input_event`
injection → `Screen::handleInputEvent` → frame transition). Parametrized
on the screen-bearing role (heltec-v3 OLED). Captures images via USB
webcam + OCRs them for HTML-report evidence. Requires `pip install -e '.[ui]'`
and `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=<index>`; tier is auto-deselected
if `cv2` isn't importable.
- **`fleet`** — PSK-seed isolation: two labs with different seeds never
overlap.
- **`admin`** — owner persistence across reboot, channel URL round-trip,
@@ -193,6 +233,42 @@ in the pre-flight header.
- **`provisioning`** — region/channel baking, userPrefs survive
`factory_reset(full=False)`.
#### UI tier setup
The `tests/ui/` tier drives the on-device OLED via the firmware's existing
`AdminMessage.send_input_event` RPC (no firmware changes required) and
verifies transitions via a macro-gated log line + camera + OCR. Summary:
1. Install extras: `pip install -e 'mcp-server/.[ui]'` — pulls in
`opencv-python-headless`, `numpy`, `easyocr`, `Pillow`. First easyocr
run downloads ~100 MB of models to `~/.EasyOCR/`; an autouse session
fixture pre-warms the reader so per-test OCR is <100 ms after that.
2. Point a USB webcam at the heltec-v3 OLED. Discover its index:
```bash
.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"
```
3. Export the per-role device env var:
```bash
export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0
```
4. Run:
```bash
./run-tests.sh tests/ui -v
```
Captures land under `tests/ui_captures/<session_seed>/<test_id>/`, one
PNG + `.ocr.txt` per `frame_capture()` call, with a per-test
`transcript.md` stepping through event → frame → OCR. The HTML report
embeds the full image strip inline (pass or fail).
On macOS, `cv2.VideoCapture(0)` triggers the TCC Camera permission prompt
on first use. Pre-grant Terminal (or your IDE's terminal) before running.
The `OpenCVBackend` fails fast on 10 consecutive black frames so a silent
permission denial surfaces as a clear error, not an empty PNG strip.
No camera? Set `MESHTASTIC_UI_CAMERA_BACKEND=null` (or leave the device var
unset). Tests still exercise the event-injection path and log assertions;
captures just become 1×1 black PNGs.
### Artifacts (regenerated every run, under `tests/`)
- `report.html` — self-contained pytest-html report. Each test gets a
@@ -217,6 +293,14 @@ Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
quit (SIGINT → SIGTERM → SIGKILL escalation).
Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera**
panel. Left side: the latest capture PNG rendered as Unicode half-blocks
(via `rich-pixels`, works in any terminal — no kitty/sixel required).
Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes
— OCR: Nodes 2/2") so you can see every event-injection and its result
as each UI test runs. Requires the `[ui]` extras for image rendering; the
transcript alone works without them.
### Slash commands
Three AI-assisted workflows are wired up for Claude Code operators
+15
View File
@@ -23,6 +23,21 @@ test = [
# consumers; revisit if install cost pushes back.
"textual>=0.50",
]
# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR
# model alone is ~100 MB and camera hardware is user-supplied.
# pip install -e '.[ui]' — full (OpenCV + easyocr)
# pip install -e '.[ui-min]' — image capture only, no OCR
ui = [
"opencv-python-headless>=4.9",
"numpy>=1.26",
"easyocr>=1.7",
"Pillow>=10.0",
# Renders the latest camera capture as Unicode half-blocks in the TUI
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel
# dependency. Pure Python, tiny.
"rich-pixels>=3.0",
]
ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"]
[project.scripts]
meshtastic-mcp = "meshtastic_mcp.__main__:main"
+34
View File
@@ -217,6 +217,40 @@ if [[ $# -eq 0 ]]; then
-v --tb=short
fi
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
# not installed, auto-deselect tests/ui so operators without the [ui]
# extra still get a green run. Printed in yellow; silent when cv2 is
# present.
_cv2_ok=0
if "$VENV_PY" -c "import cv2" >/dev/null 2>&1; then
_cv2_ok=1
fi
_running_ui=0
for _arg in "$@"; do
case "$_arg" in
*tests/ui* | tests/) _running_ui=1 ;;
*) ;;
esac
done
if [[ $_running_ui -eq 1 && $_cv2_ok -eq 0 ]]; then
printf '\033[33m[pre-flight] tests/ui tier detected, but opencv-python-headless is not installed — deselecting.\033[0m\n'
printf ' install with: .venv/bin/pip install -e "mcp-server/.[ui]"\n'
echo
set -- "$@" --ignore=tests/ui
fi
# Recovery tier needs `uhubctl` on PATH — it power-cycles devices via USB
# hub PPPS. The tier's conftest already skips cleanly, so this is just a
# friendly heads-up before the skip happens. `baked_single`'s auto-
# recovery hook also benefits from having uhubctl available across the
# whole suite.
if ! command -v uhubctl >/dev/null 2>&1; then
printf "\033[33m[pre-flight] uhubctl not found on PATH — recovery tier will skip, and\n"
printf " wedged-device auto-recovery is disabled.\033[0m\n"
printf " install with: brew install uhubctl (macOS) or apt install uhubctl (Debian/Ubuntu).\n"
echo
fi
# Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed
# their own `--report-log=...`). Consumers — notably the
# `meshtastic-mcp-test-tui` TUI — tail the reportlog for live per-test state.
+40
View File
@@ -356,6 +356,46 @@ def shutdown(
return {"ok": True, "shutting_down_in_s": seconds}
def send_input_event(
event_code: int | str,
kb_char: int = 0,
touch_x: int = 0,
touch_y: int = 0,
port: str | None = None,
) -> dict[str, Any]:
"""Inject an InputBroker event (button press / key / gesture) into the UI.
Wraps `AdminMessage.send_input_event` (handled in firmware at
src/modules/AdminModule.cpp::handleSendInputEvent). Local-only — no PKI
warmup needed since the admin message is addressed to `my_node_num`.
`event_code` accepts an int, a case-insensitive name
(`"RIGHT"` / `"input_broker_right"`), or an `InputEventCode`. The
firmware-side enum lives in src/input/InputBroker.h and is mirrored in
`meshtastic_mcp.input_events`.
"""
from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped]
from .input_events import coerce_event_code
code = coerce_event_code(event_code)
if not 0 <= kb_char <= 255:
raise ValueError(f"kb_char out of u8 range: {kb_char}")
if not 0 <= touch_x <= 65535:
raise ValueError(f"touch_x out of u16 range: {touch_x}")
if not 0 <= touch_y <= 65535:
raise ValueError(f"touch_y out of u16 range: {touch_y}")
with connect(port=port) as iface:
msg = admin_pb2.AdminMessage()
msg.send_input_event.event_code = code
msg.send_input_event.kb_char = kb_char
msg.send_input_event.touch_x = touch_x
msg.send_input_event.touch_y = touch_y
iface.localNode._sendAdmin(msg)
return {"ok": True, "event_code": code, "kb_char": kb_char}
def factory_reset(
port: str | None = None, confirm: bool = False, full: bool = False
) -> dict[str, Any]:
+286
View File
@@ -0,0 +1,286 @@
"""Cross-platform USB-webcam capture for UI tests + the `capture_screen` tool.
Backends:
- `opencv` — cv2.VideoCapture (AVFoundation on macOS, V4L2 on Linux).
- `ffmpeg` — subprocess shelling out to the system `ffmpeg` binary. Slower
per frame, but zero Python deps beyond stdlib.
- `null` — no-op stub returning a 1×1 black PNG. Used when no camera is
configured; keeps code paths alive without forcing every operator to
hook up hardware.
Environment variables (read at `get_camera()` call time):
- `MESHTASTIC_UI_CAMERA_BACKEND` — one of `opencv` / `ffmpeg` / `null` /
`auto` (default). `auto` picks opencv if `cv2` imports, else ffmpeg if
`ffmpeg --version` resolves, else null.
- `MESHTASTIC_UI_CAMERA_DEVICE` — generic default (index or path).
- `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` — per-role override, e.g.
`MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3.
Role suffix is uppercased before lookup.
Dependencies land in the optional `[ui]` extra; imports are lazy so clients
without `opencv-python-headless` installed can still import this module.
"""
from __future__ import annotations
import io
import os
import shutil
import subprocess
import sys
import time
import warnings
from pathlib import Path
from typing import Protocol
class CameraError(RuntimeError):
"""Raised when a camera backend fails to initialize or capture."""
class CameraBackend(Protocol):
name: str
def capture(self) -> bytes:
"""Return one PNG-encoded frame."""
...
def close(self) -> None: ...
# ---------- OpenCV backend -------------------------------------------------
class OpenCVBackend:
name = "opencv"
def __init__(self, device: int | str, warmup_frames: int = 5) -> None:
try:
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
except ImportError as exc:
raise CameraError(
"opencv backend requested but `cv2` is not installed. "
"Install the mcp-server [ui] extra: pip install -e '.[ui]'"
) from exc
self._cv2 = cv2
device_arg: int | str
if isinstance(device, str) and device.isdigit():
device_arg = int(device)
else:
device_arg = device
self._cap = cv2.VideoCapture(device_arg)
if not self._cap.isOpened():
raise CameraError(
f"cv2.VideoCapture({device_arg!r}) failed to open. "
"On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access."
)
# Drop the first few frames — auto-exposure + white-balance settle.
for _ in range(warmup_frames):
self._cap.read()
# Detect a stuck black-frame camera early rather than silently
# producing all-black captures.
ok, frame = self._cap.read()
if not ok or frame is None:
self._cap.release()
raise CameraError(f"camera {device_arg!r} opened but returned no frames")
def capture(self) -> bytes:
cv2 = self._cv2
ok, frame = self._cap.read()
if not ok or frame is None:
raise CameraError("cv2.VideoCapture.read() returned no frame")
success, buf = cv2.imencode(".png", frame)
if not success:
raise CameraError("cv2.imencode('.png', ...) failed")
return bytes(buf)
def close(self) -> None:
try:
self._cap.release()
except Exception: # noqa: BLE001
pass
# ---------- ffmpeg subprocess backend --------------------------------------
class FfmpegBackend:
name = "ffmpeg"
def __init__(self, device: int | str) -> None:
if shutil.which("ffmpeg") is None:
raise CameraError("ffmpeg backend requested but `ffmpeg` is not on PATH")
self._device = str(device)
# Platform-specific -f flag:
# macOS → avfoundation (index like "0")
# Linux → v4l2 (device like "/dev/video0" or "0")
if sys.platform == "darwin":
self._input_format = "avfoundation"
self._input_spec = self._device # bare index for avfoundation
else:
self._input_format = "v4l2"
self._input_spec = (
self._device
if self._device.startswith("/dev/")
else f"/dev/video{self._device}"
)
def capture(self) -> bytes:
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-f",
self._input_format,
"-i",
self._input_spec,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
]
try:
out = subprocess.run(
cmd, capture_output=True, check=True, timeout=15 # noqa: S603
)
except subprocess.CalledProcessError as exc:
raise CameraError(
f"ffmpeg capture failed (rc={exc.returncode}): {exc.stderr.decode(errors='replace')[:200]}"
) from exc
except subprocess.TimeoutExpired as exc:
raise CameraError("ffmpeg capture timed out after 15s") from exc
return out.stdout
def close(self) -> None:
pass # stateless — each capture spawns a new process
# ---------- Null backend ---------------------------------------------------
# A tiny valid 1×1 transparent PNG so callers always get a decodable image.
_BLACK_1X1_PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082"
)
class NullBackend:
name = "null"
def capture(self) -> bytes:
return _BLACK_1X1_PNG
def close(self) -> None:
pass
# ---------- Factory --------------------------------------------------------
def _resolve_device(role: str | None) -> str | None:
if role:
specific = os.environ.get(f"MESHTASTIC_UI_CAMERA_DEVICE_{role.upper()}")
if specific:
return specific
return os.environ.get("MESHTASTIC_UI_CAMERA_DEVICE")
def get_camera(role: str | None = None) -> CameraBackend:
"""Return a CameraBackend for the given device role (e.g. `"esp32s3"`).
Falls back to `NullBackend` if no camera is configured or the selected
backend fails to init — tests should treat captures as best-effort
evidence, not a blocker.
"""
backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower()
device = _resolve_device(role)
if backend in ("null", "none") or device is None:
return NullBackend()
if backend == "auto":
# Prefer opencv if importable; fall back to ffmpeg; else null.
try:
import cv2 # type: ignore[import-untyped] # noqa: F401,PLC0415
backend = "opencv"
except ImportError:
backend = "ffmpeg" if shutil.which("ffmpeg") else "null"
if backend == "opencv":
try:
return OpenCVBackend(device)
except CameraError as exc:
warnings.warn(
f"camera backend {backend!r} failed to initialize for device "
f"{device!r}: {exc}; falling back to null backend",
RuntimeWarning,
stacklevel=2,
)
return NullBackend()
if backend == "ffmpeg":
try:
return FfmpegBackend(device)
except CameraError as exc:
warnings.warn(
f"camera backend {backend!r} failed to initialize for device "
f"{device!r}: {exc}; falling back to null backend",
RuntimeWarning,
stacklevel=2,
)
return NullBackend()
if backend == "null":
return NullBackend()
raise CameraError(f"unknown MESHTASTIC_UI_CAMERA_BACKEND: {backend!r}")
def save_capture(png_bytes: bytes, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(png_bytes)
def capture_to_file(role: str | None, path: Path) -> dict[str, object]:
"""One-shot: open camera, capture, write PNG, close. Returns metadata."""
started = time.monotonic()
cam = get_camera(role)
try:
data = cam.capture()
finally:
cam.close()
save_capture(data, path)
return {
"backend": cam.name,
"path": str(path),
"bytes": len(data),
"elapsed_s": round(time.monotonic() - started, 3),
}
def _is_png(data: bytes) -> bool:
return data.startswith(b"\x89PNG\r\n\x1a\n")
# Exposed so callers can sanity-check a capture without a full PIL import.
__all__ = [
"CameraBackend",
"CameraError",
"FfmpegBackend",
"NullBackend",
"OpenCVBackend",
"capture_to_file",
"get_camera",
"save_capture",
]
# Keep `io` import used (pyflakes is picky) via a small guard used at import
# time to normalize stdin/stdout if a subclass ever needs it.
_ = io.BytesIO # noqa: SLF001
@@ -0,0 +1,83 @@
"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``.
Watches ``tests/ui_captures/<session_seed>/`` for new transcript lines
(one per ``frame_capture()`` call from the UI tier) and posts them to
the TUI. Enabled by ``MESHTASTIC_UI_TUI_CAMERA=1``.
Design mirrors ``_flashlog.py``:
- Daemon thread, cooperative stop via ``threading.Event``.
- Tolerates the captures directory not existing yet (UI tier hasn't run).
- Per-file seek state so we only forward genuinely-new lines.
"""
from __future__ import annotations
import pathlib
import threading
import time
from typing import Callable
class UiCaptureTailer(threading.Thread):
"""Recursively watch a captures root for new `transcript.md` lines.
Invokes ``post(test_id, line)`` for each new line, where ``test_id``
is derived from the path — the sanitized nodeid directory name.
"""
def __init__(
self,
root: pathlib.Path,
post: Callable[[str, str], None],
stop: threading.Event,
*,
poll_interval: float = 0.5,
) -> None:
super().__init__(daemon=True, name="uicap-tail")
self._root = root
self._post = post
self._stop = stop
self._poll_interval = poll_interval
# path → byte offset we've already read through
self._offsets: dict[pathlib.Path, int] = {}
def run(self) -> None:
while not self._stop.is_set():
try:
self._scan_once()
except Exception:
# Best-effort tailer — never bring down the TUI because a
# directory vanished mid-scan.
pass
time.sleep(self._poll_interval)
def _scan_once(self) -> None:
if not self._root.is_dir():
return
for transcript in self._root.rglob("transcript.md"):
test_id = transcript.parent.name
offset = self._offsets.get(transcript, 0)
try:
size = transcript.stat().st_size
except OSError:
continue
if size < offset:
# File truncated / rewritten — reset and re-emit.
offset = 0
if size == offset:
continue
try:
with transcript.open("rb") as fh:
fh.seek(offset)
chunk = fh.read(size - offset).decode("utf-8", errors="replace")
except OSError:
continue
for line in chunk.splitlines():
line = line.rstrip()
if not line or line.startswith("#"):
continue
try:
self._post(test_id, line)
except Exception:
return
self._offsets[transcript] = size
@@ -518,6 +518,7 @@ def _build_app(
from . import _fwlog as _fwlog_mod
from . import _history as _history_mod
from . import _reproducer as _reproducer_mod
from . import _uicap as _uicap_mod
# ---------------- Messages ----------------
@@ -548,6 +549,16 @@ def _build_app(
self.line = line
super().__init__()
class UiCaptureLine(tx.Message):
"""Live line from the UI-tier camera transcript — one per
`frame_capture()` call. Posted only when the camera panel is
enabled via `MESHTASTIC_UI_TUI_CAMERA=1`."""
def __init__(self, test_id: str, line: str) -> None:
self.test_id = test_id
self.line = line
super().__init__()
class DeviceSnapshot(tx.Message):
def __init__(self, rows: list[DeviceRow]) -> None:
self.rows = rows
@@ -871,6 +882,10 @@ def _build_app(
#pytest-pane { height: 50%; border-bottom: solid $primary-background; }
#fwlog-header { height: 1; padding: 0 1; background: $panel; }
#fwlog-pane { height: 1fr; }
#uicap-header { height: 1; padding: 0 1; background: $boost; }
#uicap-pane { height: 14; border-top: solid $primary-background; }
#uicap-image { width: 36; border-right: solid $primary-background; padding: 0 1; }
#uicap-log { width: 1fr; height: 14; }
Tree { height: 100%; }
RichLog { height: 100%; }
#device-table { height: auto; max-height: 6; }
@@ -912,6 +927,11 @@ def _build_app(
self._device_worker: DevicePollerWorker | None = None
self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None
self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None
self._uicap_worker: _uicap_mod.UiCaptureTailer | None = None
# Env-gated; only mounts the UI-capture panel when operator asks for it.
self._ui_camera_enabled = bool(
int(os.environ.get("MESHTASTIC_UI_TUI_CAMERA", "0") or "0")
)
self._tree_filter: str = ""
self._sigint_count = 0
# Firmware-log port filter: None = all, else exact port match.
@@ -959,6 +979,22 @@ def _build_app(
wrap=True,
max_lines=5000,
)
if self._ui_camera_enabled:
yield tx.Static(
"UI camera — latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
id="uicap-header",
)
with tx.Horizontal(id="uicap-pane"):
yield tx.Static(
"(waiting…)", id="uicap-image", markup=False
)
yield tx.RichLog(
id="uicap-log",
highlight=False,
markup=False,
wrap=True,
max_lines=500,
)
yield tx.DataTable(id="device-table", show_cursor=False)
yield tx.Footer()
@@ -1023,6 +1059,21 @@ def _build_app(
stop=self._stop,
)
self._flashlog_worker.start()
# UI-capture transcript tailer — only runs when the camera panel
# is enabled. Watches tests/ui_captures/**/transcript.md for new
# lines as UI tests execute.
if self._ui_camera_enabled:
captures_root = self._root / "mcp-server" / "tests" / "ui_captures"
# When the TUI is launched from inside mcp-server (the usual
# case), `self._root` is already mcp-server/, so adjust:
if not captures_root.parent.name == "mcp-server":
captures_root = self._root / "tests" / "ui_captures"
self._uicap_worker = _uicap_mod.UiCaptureTailer(
root=captures_root,
post=lambda tid, line: self.post_message(UiCaptureLine(tid, line)),
stop=self._stop,
)
self._uicap_worker.start()
self._spawn_pytest(self._pytest_args)
# Header tick (seed / runtime / sparkline re-renders at 1 Hz).
# Also refreshes the device-status column so the per-test elapsed
@@ -1217,6 +1268,84 @@ def _build_app(
log = self.query_one("#pytest-log", tx.RichLog)
log.write(f"[flash] {message.line}")
def on_ui_capture_line(self, message: Any) -> None:
"""Route a UI-capture transcript line into the camera panel.
Each line is already formatted by frame_capture — e.g.
`1. **initial** — frame 2/8 name=home — OCR: ...`. We write
the text into the RichLog AND try to render the corresponding
PNG on the left side (requires rich-pixels, Pillow).
"""
if not self._ui_camera_enabled:
return
try:
log_panel = self.query_one("#uicap-log", tx.RichLog)
except Exception:
return
log_panel.write(f"[{message.test_id}] {message.line}")
self._render_latest_ui_capture(message.test_id, message.line)
def _render_latest_ui_capture(self, test_id: str, line: str) -> None:
"""Find the PNG that corresponds to `line` and render it on the
left of the uicap pane. Soft-fails if rich-pixels isn't
installed or the PNG isn't found — operator still has the text
transcript on the right.
"""
try:
from PIL import Image # type: ignore[import-untyped]
from rich_pixels import Pixels # type: ignore[import-untyped]
except ImportError:
return
# Transcript lines look like `1. **label** — ...`. Pull the leading
# integer to locate the capture file.
import re as _re
m = _re.match(r"\s*(\d+)\.\s", line)
if not m:
return
step = int(m.group(1))
# Captures directory is sibling of tests/ — mirror the path the
# tailer watches. Search both likely layouts (in-mcp-server vs.
# firmware-root invocation).
candidates = [
self._root / "tests" / "ui_captures",
self._root / "mcp-server" / "tests" / "ui_captures",
]
captures_root = next((p for p in candidates if p.is_dir()), None)
if captures_root is None:
return
# Drill into <session_seed>/<test_id>/ — test_id is the
# sanitized nodeid the tailer already passed through.
matches = list(captures_root.rglob(f"{test_id}/{step:03d}-*.png"))
if not matches:
return
png_path = matches[-1]
try:
img = Image.open(png_path).convert("RGB")
# Resize to fit ~32 cells wide × ~12 rows tall (half-block
# renderer gives 2× vertical resolution, so 32×24 px input
# lands at ~32×12 cells). Keep aspect ratio.
target_w = 60
w, h = img.size
target_h = max(1, int(h * (target_w / max(1, w))))
# Clamp: the image panel is 14 rows; half-blocks give 2 rows
# per vertical cell, so cap pixel height at ~26.
target_h = min(target_h, 26)
img = img.resize((target_w, target_h))
pixels = Pixels.from_image(img)
except Exception:
return
try:
image_widget = self.query_one("#uicap-image", tx.Static)
image_widget.update(pixels)
except Exception:
pass
def on_firmware_log_line(self, message: Any) -> None:
rec = message.record
port = rec.get("port")
+11
View File
@@ -135,3 +135,14 @@ def picotool_bin() -> Path:
("picotool",),
"Install via `brew install picotool` or build from https://github.com/raspberrypi/picotool.",
)
def uhubctl_bin() -> Path:
return _hw_tool(
"MESHTASTIC_UHUBCTL_BIN",
("uhubctl",),
"Install via `brew install uhubctl` (macOS) or `apt install uhubctl` "
"(Debian/Ubuntu). On Linux without the udev rules, or on older macOS "
"with certain hubs, you may need to run via `sudo`: "
"https://github.com/mvp/uhubctl#linux-usb-permissions",
)
@@ -0,0 +1,67 @@
"""Python mirror of firmware `enum input_broker_event` (src/input/InputBroker.h).
Used by `admin.send_input_event` + `tests/ui/` so callers can say
`InputEventCode.RIGHT` instead of hard-coding 20. Values MUST stay in sync
with the firmware enum — unit test `tests/unit/test_input_event_codes.py`
pins the mapping.
"""
from __future__ import annotations
from enum import IntEnum
class InputEventCode(IntEnum):
"""Button / key / gesture events dispatched by the firmware InputBroker."""
NONE = 0
SELECT = 10
SELECT_LONG = 11
UP_LONG = 12
DOWN_LONG = 13
UP = 17
DOWN = 18
LEFT = 19
RIGHT = 20
CANCEL = 24
BACK = 27
# Auto-incremented values in the C enum (27 + 1, +2, +3):
USER_PRESS = 28
ALT_PRESS = 29
ALT_LONG = 30
SHUTDOWN = 0x9B
GPS_TOGGLE = 0x9E
SEND_PING = 0xAF
FN_F1 = 0xF1
FN_F2 = 0xF2
FN_F3 = 0xF3
FN_F4 = 0xF4
FN_F5 = 0xF5
MATRIXKEY = 0xFE
ANYKEY = 0xFF
def coerce_event_code(value: int | str | InputEventCode) -> int:
"""Accept an int, a case-insensitive name, or an `InputEventCode` and return
the u8 wire value. Raises ValueError on unknown names / out-of-range ints.
"""
if isinstance(value, InputEventCode):
return int(value)
if isinstance(value, int):
if not 0 <= value <= 255:
raise ValueError(f"event_code out of u8 range: {value}")
return value
if isinstance(value, str):
key = value.upper().replace("-", "_")
if key.startswith("INPUT_BROKER_"):
key = key[len("INPUT_BROKER_") :]
try:
return int(InputEventCode[key])
except KeyError as exc:
known = ", ".join(m.name for m in InputEventCode)
raise ValueError(
f"unknown event code name {value!r}; known: {known}"
) from exc
raise TypeError(
f"event_code must be int|str|InputEventCode, got {type(value).__name__}"
)
+147
View File
@@ -0,0 +1,147 @@
"""OCR wrapper for UI tests + the `capture_screen` tool.
Auto-selects a reader in priority order:
1. `easyocr` (deep-learning, high quality on OLED screens — but ~100 MB
model download on first use).
2. `pytesseract` (requires system `tesseract` binary on PATH).
3. `null` — returns `""` with a warning. Tests fall back to log + image
evidence when OCR is unavailable.
Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto`
(default `auto`).
`ocr_text(png_bytes) -> str` is the only public entry point. The reader is
constructed lazily on first call and cached, so the easyocr cold-start cost
only hits once per process.
"""
from __future__ import annotations
import functools
import logging
import os
import shutil
import sys
from typing import Callable
log = logging.getLogger(__name__)
def _backend_choice() -> str:
return os.environ.get("MESHTASTIC_UI_OCR_BACKEND", "auto").lower()
@functools.lru_cache(maxsize=1)
def _reader() -> tuple[str, Callable[[bytes], str]]:
"""Return `(backend_name, callable)` for whichever OCR is available."""
choice = _backend_choice()
def _easyocr() -> tuple[str, Callable[[bytes], str]]:
import easyocr # type: ignore[import-untyped] # noqa: PLC0415
import numpy as np # type: ignore[import-untyped] # noqa: PLC0415
reader = easyocr.Reader(["en"], gpu=False, verbose=False)
def _run(png: bytes) -> str:
try:
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
arr = np.frombuffer(png, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
except ImportError:
# Fall back to PIL if cv2 isn't around.
from io import BytesIO # noqa: PLC0415
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
img = np.array(Image.open(BytesIO(png)).convert("RGB"))
try:
results = reader.readtext(img, detail=0, paragraph=True)
except Exception as exc: # noqa: BLE001
log.warning("easyocr failed: %s", exc)
return ""
return "\n".join(str(r) for r in results)
return "easyocr", _run
def _pytesseract() -> tuple[str, Callable[[bytes], str]]:
from io import BytesIO # noqa: PLC0415
import pytesseract # type: ignore[import-untyped] # noqa: PLC0415
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
if shutil.which("tesseract") is None:
raise ImportError("`tesseract` binary not on PATH")
def _run(png: bytes) -> str:
try:
return str(pytesseract.image_to_string(Image.open(BytesIO(png))))
except Exception as exc: # noqa: BLE001
log.warning("pytesseract failed: %s", exc)
return ""
return "pytesseract", _run
def _null() -> tuple[str, Callable[[bytes], str]]:
log.warning(
"OCR backend is null; install easyocr or tesseract for text extraction"
)
return "null", lambda _png: ""
if choice == "easyocr":
return _easyocr()
if choice == "pytesseract":
return _pytesseract()
if choice == "null":
return _null()
if choice != "auto":
print(
f"[ocr] unknown MESHTASTIC_UI_OCR_BACKEND={choice!r}; falling back to auto",
file=sys.stderr,
)
# auto mode
try:
return _easyocr()
except ImportError:
pass
try:
return _pytesseract()
except ImportError:
pass
return _null()
def ocr_text(png_bytes: bytes) -> str:
"""Run OCR on a PNG-encoded image and return the decoded text (possibly empty)."""
if not png_bytes:
return ""
_, run = _reader()
return run(png_bytes)
def backend_name() -> str:
"""Return the currently-selected backend name, initializing if necessary."""
name, _ = _reader()
return name
def warm() -> None:
"""Run one dummy inference so the easyocr cold-start cost is paid upfront.
Pytest session fixture calls this once so the first real capture doesn't
eat the model-load latency.
"""
# A 64×32 white PNG — decodes clean, no text to extract.
white_png = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e"
"0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080"
"0b010000ffff030000000000000049454e44ae426082"
)
try:
ocr_text(white_png)
except Exception as exc: # noqa: BLE001
log.warning("ocr.warm() failed: %s", exc)
__all__ = ["backend_name", "ocr_text", "warm"]
+147 -1
View File
@@ -1,4 +1,4 @@
"""FastMCP server wiring — 38 tools across 7 categories.
"""FastMCP server wiring — 43 tools across 9 categories (adds uhubctl power control).
Each tool handler is a thin delegation to a named module (pio.py, admin.py,
etc.). Business logic does not live here.
@@ -513,6 +513,152 @@ def factory_reset(
return admin.factory_reset(port=port, confirm=confirm, full=full)
@app.tool()
def send_input_event(
event_code: int | str,
kb_char: int = 0,
touch_x: int = 0,
touch_y: int = 0,
port: str | None = None,
) -> dict[str, Any]:
"""Inject an InputBroker event (button / key / gesture) into the device UI.
Drives the same code path as a physical button press. Accepts a numeric
event code (0..255) or a name like `"RIGHT"`, `"SELECT"`, `"FN_F1"`.
Common codes: SELECT=10, UP=17, DOWN=18, LEFT=19, RIGHT=20, CANCEL=24,
BACK=27, FN_F1..F5=241..245.
"""
return admin.send_input_event(
event_code=event_code,
kb_char=kb_char,
touch_x=touch_x,
touch_y=touch_y,
port=port,
)
@app.tool()
def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]:
"""Grab a frame from the USB webcam pointed at the device screen.
Returns PNG bytes (base64), optional OCR text, and backend metadata.
Requires the `[ui]` extras (opencv-python-headless) and a camera
configured via `MESHTASTIC_UI_CAMERA_DEVICE[_<ROLE>]`. Falls back to a
1×1 black PNG from the null backend when no camera is configured.
"""
import base64
from . import camera as camera_mod
cam = camera_mod.get_camera(role)
try:
png = cam.capture()
finally:
cam.close()
result: dict[str, Any] = {
"backend": cam.name,
"bytes": len(png),
"image_base64": base64.b64encode(png).decode("ascii"),
}
if ocr:
from . import ocr as ocr_mod
result["ocr_backend"] = ocr_mod.backend_name()
result["ocr_text"] = ocr_mod.ocr_text(png)
return result
# ---------- USB power control (uhubctl) -----------------------------------
@app.tool()
def uhubctl_list() -> list[dict[str, Any]]:
"""List every USB hub + per-port device attachment as seen by `uhubctl`.
Read-only — no confirm required. Each hub entry includes its location
(`1-1.3`), descriptor, whether it supports Per-Port Power Switching,
and a list of populated ports with VID:PID of attached devices.
Useful for pre-flight checks before a destructive power-cycle call.
"""
from . import uhubctl as uhubctl_mod
return uhubctl_mod.list_hubs()
@app.tool()
def uhubctl_power(
action: str,
location: str | None = None,
port: int | None = None,
role: str | None = None,
confirm: bool = False,
) -> dict[str, Any]:
"""Power a USB hub port on or off via `uhubctl -a on|off`.
Target the port by either (`location`, `port`) — raw uhubctl syntax,
e.g. `location="1-1.3", port=2` — OR by `role` ("nrf52", "esp32s3").
Role lookup honors `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
`_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
`action="off"` requires `confirm=True` (destructive — the attached
device will immediately disappear from the OS).
"""
from . import uhubctl as uhubctl_mod
action_lower = action.lower()
if action_lower not in {"on", "off"}:
raise ValueError(f"action must be 'on' or 'off', got {action!r}")
if action_lower == "off" and not confirm:
raise uhubctl_mod.UhubctlError(
"uhubctl_power action='off' requires confirm=True"
)
loc, p = _resolve_uhubctl_target(location, port, role)
if action_lower == "on":
return uhubctl_mod.power_on(loc, p)
return uhubctl_mod.power_off(loc, p)
@app.tool()
def uhubctl_cycle(
location: str | None = None,
port: int | None = None,
role: str | None = None,
delay_s: int = 2,
confirm: bool = False,
) -> dict[str, Any]:
"""Power a USB hub port off, wait `delay_s` seconds, then on.
The typical hard-reset sequence — shorter than off+on as two RPCs
because uhubctl handles the timing in-process. Target by (location,
port) or by role (see `uhubctl_power`). Requires `confirm=True`.
"""
from . import uhubctl as uhubctl_mod
if not confirm:
raise uhubctl_mod.UhubctlError("uhubctl_cycle requires confirm=True")
if delay_s < 0 or delay_s > 60:
raise ValueError(f"delay_s must be 0..60, got {delay_s}")
loc, p = _resolve_uhubctl_target(location, port, role)
return uhubctl_mod.cycle(loc, p, delay_s=delay_s)
def _resolve_uhubctl_target(
location: str | None, port: int | None, role: str | None
) -> tuple[str, int]:
"""Shared arg-resolution for uhubctl_power + uhubctl_cycle."""
from . import uhubctl as uhubctl_mod
if role is not None:
if location is not None or port is not None:
raise ValueError("pass either `role` OR (`location` + `port`), not both")
return uhubctl_mod.resolve_target(role)
if location is None or port is None:
raise ValueError("must pass `role` or both `location` and `port`")
return (location, int(port))
# ---------- Direct hardware tools -----------------------------------------
+321
View File
@@ -0,0 +1,321 @@
"""USB hub power control via `uhubctl` — hard-recovery for wedged devices +
deliberate offline-peer simulation for mesh tests.
Why: when a Meshtastic device's serial port wedges (stuck in a boot loop,
frozen USB CDC, crashed firmware that didn't reboot), the only recovery is
a physical unplug. uhubctl toggles VBUS per-port on any hub with Per-Port
Power Switching (PPPS) support — which is most externally-powered hubs
from the last ~5 years — so the harness can power-cycle a device
programmatically.
Architecture:
- `list_hubs()` parses `uhubctl` default output into structured records.
- `find_port_for_vid(vid)` walks the hubs to find which location+port
hosts a given USB VID.
- `resolve_target(role)` is the public entry for callers that know a role
(`nrf52`, `esp32s3`) but not a hub location: env-var pins win, VID
auto-detect falls back.
- `power_on`, `power_off`, `cycle` wrap the corresponding `uhubctl -a`
invocations, routed through `hw_tools._run` so they share tee-to-flash-
log + timeout handling with esptool / nrfutil / picotool.
Sudo policy: **fail fast**. Modern macOS + most PPPS-capable hubs work
without root, but Linux without udev rules (or old macOS with specific
driver quirks) still needs it. We run uhubctl non-root; if stderr
matches the classic permission pattern we raise `UhubctlError` with an
install hint pointing at the uhubctl docs. Auto-wrapping with `sudo`
would prompt in the middle of test runs — bad for CI.
"""
from __future__ import annotations
import os
import re
from typing import Any, Sequence
from . import config, hw_tools
# ---------- Parser ---------------------------------------------------------
# Hub descriptor line:
# Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
_HUB_RE = re.compile(
r"^Current status for hub (?P<location>\S+)\s+\[(?P<descriptor>.+)\]\s*$"
)
# Port line:
# " Port 2: 0103 power enable connect [239a:8029 RAKwireless ...]"
# The bracketed section is absent for empty ports.
_PORT_RE = re.compile(
r"^\s+Port\s+(?P<port>\d+):\s+(?P<status>\S+)\s+(?P<flags>.*?)"
r"(?:\s+\[(?P<device_vid>[0-9a-fA-F]{4}):(?P<device_pid>[0-9a-fA-F]{4})(?:\s+(?P<device_desc>.+))?\])?\s*$"
)
class UhubctlError(RuntimeError):
"""Raised on uhubctl-specific failures: parse errors, permission denied,
hub-not-found, or PPPS not supported."""
# ---------- Role → VID map -------------------------------------------------
# Mirrors the default hub_profile in `mcp-server/tests/conftest.py:335`.
# Note: esp32s3 and esp32s3_alt share a logical role — we search both.
ROLE_VIDS: dict[str, tuple[int, ...]] = {
"nrf52": (0x239A,),
"esp32s3": (0x303A, 0x10C4),
}
def _normalize_role(role: str) -> str:
"""Collapse `esp32s3_alt` → `esp32s3` to match the tier conventions."""
return role.split("_alt", 1)[0].lower()
# ---------- Core subprocess runner -----------------------------------------
# If uhubctl hits a permission problem — most commonly Linux without the
# udev rules, or a macOS variant where the kernel holds the hub driver —
# it prints something like "Permission denied. Try running as root".
# Linux error text varies; we match a broad substring rather than exact.
_PERM_ERROR_PATTERNS = (
"permission denied",
"operation not permitted",
"try running as root",
"need root",
"requires root",
)
def _run_uhubctl(args: Sequence[str], *, timeout: float = 30.0) -> dict[str, Any]:
"""Invoke uhubctl with the given args. Returns `hw_tools._run`'s dict.
Translates permission-denied failures into a `UhubctlError` with the
install hint, so callers don't have to match stderr themselves. Other
non-zero exits are returned as-is for the caller to interpret.
"""
binary = config.uhubctl_bin()
result = hw_tools._run(binary, args, timeout=timeout) # noqa: SLF001
if result["exit_code"] != 0:
combined = (result.get("stderr") or "") + "\n" + (result.get("stdout") or "")
lower = combined.lower()
if any(pat in lower for pat in _PERM_ERROR_PATTERNS):
raise UhubctlError(
"uhubctl exited with a permission error. Install the udev "
"rules on Linux, or try `sudo` as a fallback: "
"https://github.com/mvp/uhubctl#linux-usb-permissions\n"
f"stderr: {result.get('stderr_tail')!r}"
)
return result
# ---------- List / parse ---------------------------------------------------
def parse_list_output(output: str) -> list[dict[str, Any]]:
"""Parse the default `uhubctl` stdout into structured hubs.
Each hub: {
"location": "1-1.3",
"descriptor": "2109:2817 VIA Labs ...",
"vid": 0x2109,
"pid": 0x2817,
"ppps": bool,
"ports": [{"port": int, "status": str, "flags": str,
"device_vid": int | None, "device_pid": int | None,
"device_desc": str | None}, ...],
}
"""
hubs: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for line in output.splitlines():
hm = _HUB_RE.match(line)
if hm:
descriptor = hm.group("descriptor")
hub_vid, hub_pid = None, None
vid_match = re.match(r"([0-9a-fA-F]{4}):([0-9a-fA-F]{4})", descriptor)
if vid_match:
hub_vid = int(vid_match.group(1), 16)
hub_pid = int(vid_match.group(2), 16)
current = {
"location": hm.group("location"),
"descriptor": descriptor,
"vid": hub_vid,
"pid": hub_pid,
"ppps": ", ppps" in descriptor or descriptor.endswith("ppps"),
"ports": [],
}
hubs.append(current)
continue
pm = _PORT_RE.match(line)
if pm and current is not None:
device_vid = pm.group("device_vid")
device_pid = pm.group("device_pid")
current["ports"].append(
{
"port": int(pm.group("port")),
"status": pm.group("status"),
"flags": (pm.group("flags") or "").strip(),
"device_vid": int(device_vid, 16) if device_vid else None,
"device_pid": int(device_pid, 16) if device_pid else None,
"device_desc": (pm.group("device_desc") or "").strip() or None,
}
)
return hubs
def list_hubs() -> list[dict[str, Any]]:
"""Enumerate every hub uhubctl can see, with per-port device attachments.
Pure read — no power state changes. Useful as a pre-flight check before
a destructive `power_off` call.
"""
result = _run_uhubctl([], timeout=15.0)
if result["exit_code"] != 0:
raise UhubctlError(
f"uhubctl list failed (exit {result['exit_code']}): {result.get('stderr_tail')!r}"
)
return parse_list_output(result["stdout"])
# ---------- Lookup / resolution -------------------------------------------
def find_port_for_vid(
vid: int, pid: int | None = None, *, only_ppps: bool = True
) -> list[tuple[str, int]]:
"""Return ALL (location, port) matches for a device VID (optionally +PID).
`only_ppps=True` filters out hubs that don't advertise PPPS — we can't
control them anyway. Callers that want to diagnose a missing device can
pass `only_ppps=False` to see if the device is on a non-controllable
hub (and raise a clearer error).
"""
hubs = list_hubs()
matches: list[tuple[str, int]] = []
for hub in hubs:
if only_ppps and not hub["ppps"]:
continue
for port in hub["ports"]:
if port["device_vid"] != vid:
continue
if pid is not None and port["device_pid"] != pid:
continue
matches.append((hub["location"], port["port"]))
return matches
def resolve_target(role: str) -> tuple[str, int]:
"""Resolve a Meshtastic role to (hub_location, port_number).
Priority:
1. Env vars `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` + `_PORT_<ROLE>`
(e.g. `MESHTASTIC_UHUBCTL_LOCATION_NRF52=1-1.3`, `_PORT_NRF52=2`).
2. VID auto-detect against `ROLE_VIDS[role]`, taking the first PPPS
match.
Raises `UhubctlError` on ambiguity (multiple matches) or no-match. The
env-var path exists specifically to disambiguate when two devices share
a VID.
"""
role = _normalize_role(role)
env_key_loc = f"MESHTASTIC_UHUBCTL_LOCATION_{role.upper()}"
env_key_port = f"MESHTASTIC_UHUBCTL_PORT_{role.upper()}"
loc = os.environ.get(env_key_loc)
port_str = os.environ.get(env_key_port)
if loc and port_str:
try:
return (loc, int(port_str))
except ValueError as exc:
raise UhubctlError(
f"{env_key_port}={port_str!r} is not a valid integer"
) from exc
if role not in ROLE_VIDS:
raise UhubctlError(
f"unknown role {role!r}; known roles: {sorted(ROLE_VIDS)}. "
f"Set {env_key_loc} + {env_key_port} to pin manually."
)
matches: list[tuple[str, int]] = []
for vid in ROLE_VIDS[role]:
matches.extend(find_port_for_vid(vid))
if not matches:
vids = ", ".join(f"0x{v:04x}" for v in ROLE_VIDS[role])
raise UhubctlError(
f"no controllable hub hosts a device with VID in {{{vids}}} "
f"for role={role!r}. Check the device is plugged into a "
f"PPPS-capable hub, or pin manually via {env_key_loc} + {env_key_port}."
)
if len(matches) > 1:
shown = ", ".join(f"{loc}:port{p}" for loc, p in matches)
raise UhubctlError(
f"ambiguous: multiple devices match role={role!r} ({shown}). "
f"Pin the target via {env_key_loc} + {env_key_port}."
)
return matches[0]
# ---------- Power actions --------------------------------------------------
def _action(
action: str,
location: str,
port: int,
*,
delay_s: int | None = None,
timeout: float = 30.0,
) -> dict[str, Any]:
args: list[str] = ["-a", action, "-l", location, "-p", str(port)]
if delay_s is not None:
args.extend(["-d", str(delay_s)])
# Suppress verbose "before" printout so our parser doesn't have to skip it.
args.append("-N")
result = _run_uhubctl(args, timeout=timeout)
if result["exit_code"] != 0:
raise UhubctlError(
f"uhubctl -a {action} -l {location} -p {port} failed "
f"(exit {result['exit_code']}): {result.get('stderr_tail')!r}"
)
return {
"action": action,
"location": location,
"port": port,
"delay_s": delay_s,
"duration_s": result["duration_s"],
}
def power_on(location: str, port: int) -> dict[str, Any]:
"""Drive the port VBUS high. Device re-enumerates in 1-3 s on healthy hubs."""
return _action("on", location, port)
def power_off(location: str, port: int) -> dict[str, Any]:
"""Drive the port VBUS low. Device disappears from `list_devices` immediately."""
return _action("off", location, port)
def cycle(location: str, port: int, delay_s: int = 2) -> dict[str, Any]:
"""Off → wait `delay_s` → on. The common hard-reset pattern."""
# uhubctl's own `-a cycle` handles the delay internally; we use a
# slightly longer timeout to accommodate delay_s + enumeration.
return _action("cycle", location, port, delay_s=delay_s, timeout=30.0 + delay_s * 2)
__all__ = [
"ROLE_VIDS",
"UhubctlError",
"cycle",
"find_port_for_vid",
"list_hubs",
"parse_list_output",
"power_off",
"power_on",
"resolve_target",
]
@@ -393,6 +393,7 @@ def build_testing_profile(
long_name: str | None = None,
disable_mqtt: bool = True,
disable_position: bool = False,
enable_ui_log: bool = False,
) -> dict[str, Any]:
"""Build a USERPREFS dict for an isolated test-mesh device.
@@ -423,6 +424,10 @@ def build_testing_profile(
traffic never leaks to a public broker.
disable_position: if True, disables GPS + position broadcasts — useful
when test devices sit on a bench without antennas.
enable_ui_log: if True, stamps `USERPREFS_UI_TEST_LOG=true` so the
firmware emits one `Screen: frame N/M name=... reason=...` log
line per frame transition. Test-only; off by default because the
log is chatty (multiple times per second during UI interaction).
"""
if region not in KNOWN_REGIONS:
@@ -475,6 +480,9 @@ def build_testing_profile(
prefs["USERPREFS_CONFIG_OWNER_LONG_NAME"] = long_name
if short_name is not None:
prefs["USERPREFS_CONFIG_OWNER_SHORT_NAME"] = short_name
if enable_ui_log:
# Consumed by `#ifdef USERPREFS_UI_TEST_LOG` in src/graphics/Screen.cpp.
prefs["USERPREFS_UI_TEST_LOG"] = True
return prefs
+112
View File
@@ -0,0 +1,112 @@
"""USB hub power control for tests — thin composition of the `uhubctl`
module + `_port_discovery.resolve_port_by_role`.
Why separate from the production module:
- `meshtastic_mcp.uhubctl.cycle` returns as soon as uhubctl exits (VBUS is
back on, but the device hasn't finished enumerating as a CDC port yet).
- Tests that want to immediately issue a `connect(port=...)` need the NEW
`/dev/cu.*` path, which can differ from the pre-cycle path on nRF52
boards (CDC re-enumeration assigns a fresh `cu.usbmodemNNNN`).
- `resolve_port_by_role` already handles that wait + path-resolution for
the `factory_reset` flow. Composing the two gives a one-call helper.
Also exposes `is_uhubctl_available()` so fixtures can skip cleanly when
uhubctl isn't installed — we never want "no uhubctl" to look like a test
failure.
"""
from __future__ import annotations
import time
from typing import Any
from meshtastic_mcp import config as config_mod
from meshtastic_mcp import uhubctl as uhubctl_mod
from ._port_discovery import resolve_port_by_role
def is_uhubctl_available() -> bool:
"""Return True iff `config.uhubctl_bin()` resolves AND the binary is callable.
Soft-fails silently — fixtures use this to `pytest.skip` with an
actionable message when the operator hasn't installed uhubctl.
"""
try:
config_mod.uhubctl_bin()
except Exception: # noqa: BLE001
return False
# Do NOT actually invoke uhubctl here — on macOS a non-sudo run would
# fail, which is a config issue, not a tool-missing issue. That gets
# surfaced to the user when they actually run a recovery action.
return True
def power_on(role: str) -> dict[str, Any]:
"""Power on the hub port hosting `role`. Does NOT wait for re-enumeration.
Use `power_cycle` or follow with `resolve_port_by_role` to block on readiness.
"""
loc, port = uhubctl_mod.resolve_target(role)
return uhubctl_mod.power_on(loc, port)
def power_off(role: str) -> dict[str, Any]:
"""Power off the hub port hosting `role`. The device disappears from
`list_devices` immediately.
"""
loc, port = uhubctl_mod.resolve_target(role)
return uhubctl_mod.power_off(loc, port)
def power_cycle(
role: str,
*,
delay_s: int = 2,
rediscover_timeout_s: float = 30.0,
) -> str:
"""Cycle the port hosting `role`, wait for re-enumeration, return the
new port path.
On nRF52 the post-cycle path typically matches the pre-cycle path, but
macOS may assign a different `/dev/cu.usbmodemNNNN` if the previous
CDC endpoint hasn't been fully released. `resolve_port_by_role`
handles that transparently.
"""
loc, port = uhubctl_mod.resolve_target(role)
uhubctl_mod.cycle(loc, port, delay_s=delay_s)
# After uhubctl exits, VBUS is on but the device may still be in
# bootloader init. Give it ~500 ms head-start before polling so we
# don't spam list_devices pointlessly.
time.sleep(0.5)
return resolve_port_by_role(role, timeout_s=rediscover_timeout_s)
def wait_for_absence(role: str, *, timeout_s: float = 10.0) -> None:
"""Block until a device matching `role` is NOT in `list_devices`.
Used by the recovery tier to assert power_off actually took effect.
Raises TimeoutError on failure.
"""
from meshtastic_mcp import devices as devices_mod
from ._port_discovery import _ROLE_VIDS, _coerce_vid # type: ignore[attr-defined]
if role not in _ROLE_VIDS:
raise ValueError(f"unknown role {role!r}")
wanted = _ROLE_VIDS[role]
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
found = devices_mod.list_devices(include_unknown=True)
if not any(_coerce_vid(d.get("vid")) in wanted for d in found):
return
time.sleep(0.3)
raise TimeoutError(f"role {role!r} still visible after {timeout_s}s of power_off")
__all__ = [
"is_uhubctl_available",
"power_cycle",
"power_off",
"power_on",
"wait_for_absence",
]
+137 -6
View File
@@ -123,15 +123,24 @@ def pytest_collection_modifyitems(
return (2, item.nodeid)
if "/monitor/" in path or "tests/monitor" in path:
return (3, item.nodeid)
if "/fleet/" in path or "tests/fleet" in path:
# Recovery tier: explicitly cycles device power via uhubctl. Slots
# between monitor (read-only) and ui (state-preserving) so any tier
# after it starts from a known re-enumerated + re-verified state.
if "/recovery/" in path or "tests/recovery" in path:
return (4, item.nodeid)
# UI tier slots here — read-only w.r.t. mesh state, only mutates
# the on-screen UI (BACK×5 guard restores home before each test).
if "/ui/" in path or "tests/ui" in path:
return (5, item.nodeid)
if "/fleet/" in path or "tests/fleet" in path:
return (6, item.nodeid)
# State-mutating tiers run last.
if "/admin/" in path or "tests/admin" in path:
return (5, item.nodeid)
return (7, item.nodeid)
if "/provisioning/" in path or "tests/provisioning" in path:
return (6, item.nodeid)
return (8, item.nodeid)
# Top-level + anything else falls between.
return (7, item.nodeid)
return (9, item.nodeid)
items.sort(key=sort_key)
@@ -156,13 +165,20 @@ def session_seed(request: pytest.FixtureRequest) -> str:
@pytest.fixture(scope="session")
def test_profile(session_seed: str) -> dict[str, Any]:
"""The canonical isolated-mesh test profile for this session."""
"""The canonical isolated-mesh test profile for this session.
`enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware
emits `Screen: frame N/M name=... reason=...` log lines per UI
transition — consumed by the `tests/ui/` tier. Harmless on boards
without a screen (the `#ifdef` sits behind `HAS_SCREEN`).
"""
return userprefs.build_testing_profile(
psk_seed=session_seed,
channel_name="McpTest",
channel_num=88,
region="US",
modem_preset="LONG_FAST",
enable_ui_log=True,
)
@@ -654,6 +670,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
def baked_single(
baked_mesh: dict[str, Any],
baked_single_role: str,
hub_devices: dict[str, str],
) -> dict[str, Any]:
"""Function-scoped: a single verified baked device.
@@ -662,10 +679,75 @@ def baked_single(
(e.g. `test_owner_survives_reboot[nrf52]` +
`test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role
and never skip a device that happens to be connected.
Auto-recovery: if the baked device fails a pre-test `device_info` probe
AND uhubctl is available, power-cycle the port once and retry. Without
uhubctl, surface the wedge as a clear skip. This catches "device got
stuck between tests" without masking persistent regressions (a second
wedge after cycling still skips).
"""
if baked_single_role not in baked_mesh:
pytest.skip(f"role {baked_single_role!r} not present on the hub")
return {"role": baked_single_role, **baked_mesh[baked_single_role]}
entry = baked_mesh[baked_single_role]
port = entry.get("port")
if port:
try:
_run_with_timeout(lambda: info.device_info(port=port, timeout_s=3.0), 5.0)
except Exception:
# Device didn't respond. Try a power-cycle recovery if uhubctl
# is installed; otherwise surface a skip that names the root
# cause clearly.
from tests import _power
if not _power.is_uhubctl_available():
pytest.skip(
f"device {baked_single_role!r} unresponsive on {port}; "
"install uhubctl (`brew install uhubctl` / `apt install "
"uhubctl`) for auto power-cycle recovery"
)
try:
new_port = _power.power_cycle(baked_single_role, delay_s=2)
except Exception as exc: # noqa: BLE001
pytest.skip(
f"device {baked_single_role!r} wedged and power-cycle "
f"failed: {exc}"
)
# Mutate both the session-scoped `hub_devices` map AND the
# baked_mesh entry so downstream fixtures see the recovered port.
hub_devices[baked_single_role] = new_port
baked_mesh[baked_single_role]["port"] = new_port
entry = baked_mesh[baked_single_role]
return {"role": baked_single_role, **entry}
@pytest.fixture
def power_cycle(
hub_devices: dict[str, str],
) -> Callable[..., str]:
"""Return a callable `(role, delay_s=2) -> new_port` that hard-resets the
hub port hosting `role`. Skips the test cleanly when uhubctl isn't
installed — never want "no uhubctl" to look like a test failure.
The callable mutates `hub_devices[role]` in place so subsequent fixture
lookups pick up the post-cycle port (mirrors the pattern in
provisioning/test_userprefs_survive_factory_reset.py).
"""
from tests import _power
if not _power.is_uhubctl_available():
pytest.skip(
"uhubctl not installed; this test needs it for power control. "
"Install via `brew install uhubctl` (macOS) or `apt install "
"uhubctl` (Debian/Ubuntu)."
)
def _cycle(role: str, delay_s: int = 2) -> str:
new_port = _power.power_cycle(role, delay_s=delay_s)
hub_devices[role] = new_port
return new_port
return _cycle
_DEFAULT_ROLE_ENVS = {
@@ -960,6 +1042,45 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any:
raise TimeoutError(f"operation did not complete within {timeout}s") from exc
def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
"""Embed per-step UI captures (PNG + OCR) into the pytest-html extras.
Runs for every UI-tier test on BOTH pass and fail so the HTML report
always shows the image strip + OCR transcript. Silently no-ops if
pytest-html isn't installed or the test didn't use `frame_capture`.
"""
captures = getattr(item, "_ui_captures", None)
if not captures:
return
try:
from pytest_html import extras as html_extras # type: ignore[import-untyped]
except ImportError:
return
existing = getattr(report, "extras", None) or []
extras_list = list(existing)
for cap in captures:
png_path = cap.get("png_path")
label = f"{cap.get('step', '?')}: {cap.get('label', '')}"
frame = cap.get("frame") or {}
frame_str = (
f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
)
if png_path:
try:
with open(png_path, "rb") as fh:
import base64
b64 = base64.b64encode(fh.read()).decode("ascii")
extras_list.append(html_extras.png(b64, name=f"{label}{frame_str}"))
except OSError:
pass
ocr = (cap.get("ocr_text") or "").strip()
if ocr:
extras_list.append(html_extras.text(ocr, name=f"OCR: {label}{frame_str}"))
report.extras = extras_list # type: ignore[attr-defined]
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any:
"""On test failure, attach serial capture + device state as report artifacts.
@@ -967,10 +1088,20 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck
port, unbaked firmware, dead board), the dump is skipped rather than
hanging the session.
For UI-tier tests, also embeds per-step camera captures + OCR on every
test (pass or fail) so the HTML report shows visual evidence of what
the device did.
"""
outcome = yield
report = outcome.get_result()
# Attach UI captures on any outcome (pass + fail) — these are the whole
# point of the UI tier. Do this before the failure-only branch below so
# passing tests still get their image strip.
if report.when == "call":
_attach_ui_captures(item, report)
if report.when != "call" or report.outcome != "failed":
return
@@ -0,0 +1,155 @@
"""Isolation test for peer-offline-then-back mid-conversation.
Verifies the mesh stack's behavior when a peer is physically powered
off mid-send via uhubctl, then powered back on.
Flow (parametrized over every directed mesh_pair):
1. Bilateral PKI warmup (same pattern as test_direct_with_ack).
2. TX sends a broadcast text "msg-1" — RX confirms receipt via pubsub.
3. Power OFF RX via uhubctl. The RX device disappears from the OS.
4. TX sends a directed text "msg-2" with wantAck=True. Firmware retries
internally for ~30s before giving up. Assertion: the packet object
was accepted by the TX stack (non-None) — we don't assert an ACK
since there's no peer to send one.
5. Power ON RX. Wait for re-enumeration + boot.
6. Bilateral PKI re-nudge — RX's in-RAM PKI cache was wiped on reboot,
so the first directed send may err=35 without a fresh NodeInfo ping.
7. TX sends a directed "msg-3" — RX receives it via pubsub, confirming
the mesh recovered.
Skips cleanly if uhubctl isn't installed (via the `power_cycle` fixture's
auto-skip). Skips for pair directions where RX isn't power-controllable
(e.g. a USB-IF hub that doesn't support PPPS for its port).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from tests import _power
from tests._port_discovery import resolve_port_by_role
from ._receive import ReceiveCollector, nudge_nodeinfo
@pytest.mark.timeout(360)
def test_peer_offline_then_recovers(
mesh_pair: dict[str, Any],
power_cycle, # noqa: ARG001 — forces uhubctl-availability skip
hub_devices: dict[str, str],
) -> None:
tx_port = mesh_pair["tx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
unique_pre = f"peer-offline-pre-{tx_role}-to-{rx_role}-{int(time.time())}"
unique_post = f"peer-offline-post-{tx_role}-to-{rx_role}-{int(time.time())}"
# Step 1 + 2: warm up + confirm baseline delivery works before the test.
with ReceiveCollector(
mesh_pair["rx"]["port"], topic="meshtastic.receive.text"
) as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
# Wait for bilateral PKI (RX pubkey in TX's nodesByNum).
deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
while time.monotonic() < deadline:
rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {})
if rec.get("user", {}).get("publicKey"):
break
if time.monotonic() - last_nudge > 15.0:
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
last_nudge = time.monotonic()
time.sleep(1.0)
else:
pytest.skip(
f"bilateral PKI never completed ({tx_role}→{rx_role}); "
"can't run the offline test without a warm baseline"
)
tx_iface.sendText(unique_pre, destinationId=rx_node_num, wantAck=True)
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique_pre,
timeout=30,
)
assert got is not None, (
f"baseline directed send ({tx_role}→{rx_role}) didn't land — "
"skipping offline test to avoid false positive"
)
# Step 3: power off RX. uhubctl skips the test with a clear message if
# the RX role isn't on a controllable hub.
try:
_power.power_off(rx_role)
except Exception as exc: # noqa: BLE001
pytest.skip(f"can't power-control {rx_role!r}: {exc}")
try:
_power.wait_for_absence(rx_role, timeout_s=10.0)
except TimeoutError:
_power.power_on(rx_role) # restore hub state before failing
resolve_port_by_role(rx_role, timeout_s=30.0)
pytest.fail(f"{rx_role!r} didn't disappear after power_off")
# Step 4: send to a peer that isn't there. Firmware will retry
# internally. We don't wait for an ACK (there won't be one); we just
# confirm TX's stack accepts the packet without crashing.
try:
with connect(port=tx_port) as tx_iface:
packet = tx_iface.sendText(
f"while-offline-{rx_role}",
destinationId=rx_node_num,
wantAck=True,
)
assert packet is not None
# Give firmware a moment to do a retry or two while RX is down.
time.sleep(5.0)
except Exception as exc: # noqa: BLE001 — TX should survive the peer being gone
# Restore RX before reraising so the bench state is sane.
_power.power_on(rx_role)
resolve_port_by_role(rx_role, timeout_s=30.0)
raise AssertionError(f"TX crashed when sending to offline peer: {exc}") from exc
# Step 5: power RX back on + rediscover.
_power.power_on(rx_role)
time.sleep(0.5)
new_rx_port = resolve_port_by_role(rx_role, timeout_s=30.0)
hub_devices[rx_role] = new_rx_port
# Step 6 + 7: bilateral re-warmup + directed send that should now work.
with ReceiveCollector(new_rx_port, topic="meshtastic.receive.text") as rx:
# RX rebooted → its PKI cache is gone. Re-warm.
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
time.sleep(3.0)
got = None
for _attempt in range(3):
packet = tx_iface.sendText(
unique_post,
destinationId=rx_node_num,
wantAck=True,
)
assert packet is not None
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique_post,
timeout=30,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(5.0)
assert got is not None, (
f"post-recovery directed send {unique_post!r} ({tx_role}→{rx_role}) "
"never landed — recovery path may be broken"
)
+6
View File
@@ -0,0 +1,6 @@
"""Recovery tier — exercises `uhubctl` power control end-to-end.
Requires `uhubctl` installed AND at least one connected device on a
PPPS-capable hub. The whole tier skips cleanly via
`tests/recovery/conftest.py::_recovery_tier_guard` when either is missing.
"""
+44
View File
@@ -0,0 +1,44 @@
"""Recovery-tier gating + shared helpers.
Session-scoped guard skips the whole tier when uhubctl isn't installed.
Tests under this directory assume uhubctl is callable AND that at least
one hub role is detected on a PPPS-capable port.
"""
from __future__ import annotations
import pytest
@pytest.fixture(scope="session", autouse=True)
def _recovery_tier_guard() -> None:
"""Skip the tier when uhubctl is unavailable OR no device is on a
PPPS-capable hub. Prints the specific reason so operators know what
to fix."""
from tests import _power
if not _power.is_uhubctl_available():
pytest.skip(
"uhubctl not installed; recovery tier needs it. "
"Install via `brew install uhubctl` or `apt install uhubctl`.",
allow_module_level=True,
)
# Probe: can we even list hubs? (A macOS user without sudo gets a
# permission error here — we'd rather find out once at tier-start than
# 6 tests later.)
from meshtastic_mcp import uhubctl
try:
hubs = uhubctl.list_hubs()
except uhubctl.UhubctlError as exc:
pytest.skip(
f"uhubctl list failed: {exc}. Try the udev rules or `sudo` as a fallback.",
allow_module_level=True,
)
if not any(h["ppps"] for h in hubs):
pytest.skip(
"no PPPS-capable hubs detected — recovery tier has nothing to exercise.",
allow_module_level=True,
)
@@ -0,0 +1,43 @@
"""Smoke test: `uhubctl_list` returns a well-formed structure.
No destructive action. Runs first in the tier as a sanity check that the
tier's dependencies (uhubctl binary + permissions) are actually satisfied.
"""
from __future__ import annotations
import pytest
from meshtastic_mcp import uhubctl
@pytest.mark.timeout(30)
def test_list_hubs_returns_at_least_one_ppps_hub() -> None:
hubs = uhubctl.list_hubs()
assert hubs, "uhubctl found no hubs at all — is a USB hub connected?"
assert any(h["ppps"] for h in hubs), (
"no PPPS-capable hubs detected; power control won't work. "
"Check that the hub supports Per-Port Power Switching."
)
@pytest.mark.timeout(30)
def test_list_hubs_structure(hub_devices: dict[str, str]) -> None:
hubs = uhubctl.list_hubs()
for hub in hubs:
assert "location" in hub and hub["location"]
assert "ports" in hub and isinstance(hub["ports"], list)
for port in hub["ports"]:
assert "port" in port and isinstance(port["port"], int)
assert "status" in port
# At least one of the detected Meshtastic roles should show up in some
# port's device_vid — otherwise the recovery tier can't drive them.
seen_vids = {
p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None
}
expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids
assert expected_any or not hub_devices, (
f"hub_devices detected roles {list(hub_devices)} but uhubctl sees "
f"VIDs {sorted(hex(v) for v in seen_vids)} — the devices may be on "
"a hub that uhubctl can't see (e.g. built-in laptop ports)."
)
@@ -0,0 +1,60 @@
"""Hard reset via uhubctl must NOT wipe NVS. Verify the test profile's
region + channel survive a power-cycle.
Guards against a regression where a firmware change treats unexpected
power loss as a factory-reset trigger (e.g. bad EEPROM wear-leveling,
erase-on-boot-for-safety). Such a regression would be catastrophic for
field deployments.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp import admin, info
from tests import _power
from tests._port_discovery import resolve_port_by_role
@pytest.mark.timeout(180)
def test_lora_config_survives_power_cycle(
baked_single: dict[str, object],
test_profile: dict[str, object],
) -> None:
role = baked_single["role"]
pre_port = baked_single["port"]
pre_config = admin.get_config(section="lora", port=pre_port)["config"]["lora"]
pre_region = pre_config.get("region")
pre_preset = pre_config.get("modem_preset")
assert pre_region, f"lora.region not set pre-cycle on {role}"
# Hard power-cycle.
_power.power_cycle(role, delay_s=2)
time.sleep(0.5)
new_port = resolve_port_by_role(role, timeout_s=30.0)
# Let the firmware complete boot before admin reads.
time.sleep(2.0)
# Quick readiness probe.
probe = info.device_info(port=new_port, timeout_s=10.0)
assert (
probe.get("my_node_num") is not None
), f"device {role!r} didn't respond after power-cycle"
post_config = admin.get_config(section="lora", port=new_port)["config"]["lora"]
post_region = post_config.get("region")
post_preset = post_config.get("modem_preset")
assert post_region == pre_region, (
f"lora.region wiped by power-cycle on {role}: "
f"pre={pre_region!r} post={post_region!r}"
)
assert post_preset == pre_preset, (
f"lora.modem_preset wiped by power-cycle on {role}: "
f"pre={pre_preset!r} post={post_preset!r}"
)
# Channel-0 name should also match the test profile.
pri_ch = admin.get_channel_url(port=new_port)
assert pri_ch.get("url"), f"channel URL empty after power-cycle on {role}"
@@ -0,0 +1,61 @@
"""Full power-cycle round-trip: off → verify gone → on → verify identity
preserved.
Parametrized over every connected role. Validates both the uhubctl
plumbing AND that the device survives a hard reset with the same
`my_node_num` (no firmware-level identity regeneration).
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp import info
from tests import _power
from tests._port_discovery import resolve_port_by_role
@pytest.mark.timeout(180)
def test_power_cycle_preserves_node_identity(
baked_single: dict[str, object],
) -> None:
role = baked_single["role"]
pre_port = baked_single["port"]
pre_node_num = baked_single["my_node_num"]
pre_fw = baked_single.get("firmware_version")
# Record pre-cycle state.
pre_info = info.device_info(port=pre_port, timeout_s=5.0)
assert pre_info.get("my_node_num") == pre_node_num
# Power off; confirm the device actually disappears from list_devices.
_power.power_off(role)
try:
_power.wait_for_absence(role, timeout_s=10.0)
except TimeoutError:
# If it didn't disappear, power it back on so we don't leave the
# hub in a weird state for the next test.
_power.power_on(role)
resolve_port_by_role(role, timeout_s=30.0)
pytest.fail(f"device {role!r} stayed visible after power_off")
# Power back on + re-discover port.
_power.power_on(role)
time.sleep(0.5) # head-start before polling
new_port = resolve_port_by_role(role, timeout_s=30.0)
# Give the firmware a moment to finish boot before we hit it with admin.
time.sleep(2.0)
post_info = info.device_info(port=new_port, timeout_s=10.0)
assert post_info.get("my_node_num") == pre_node_num, (
f"my_node_num changed across power-cycle: pre={pre_node_num:#x} "
f"post={post_info.get('my_node_num'):#x}"
)
# Firmware version must match (same bake, not a re-flash).
if pre_fw:
assert post_info.get("firmware_version") == pre_fw, (
f"firmware changed across cycle: pre={pre_fw} "
f"post={post_info.get('firmware_version')}"
)
+7
View File
@@ -73,6 +73,13 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
"reboot": ("meshtastic_mcp.admin", "reboot"),
"shutdown": ("meshtastic_mcp.admin", "shutdown"),
"factory_reset": ("meshtastic_mcp.admin", "factory_reset"),
"send_input_event": ("meshtastic_mcp.admin", "send_input_event"),
# `capture_screen` in server.py calls camera.get_camera — instrument that.
"capture_screen": ("meshtastic_mcp.camera", "get_camera"),
# USB power control via uhubctl.
"uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"),
"uhubctl_power": ("meshtastic_mcp.uhubctl", "power_on"),
"uhubctl_cycle": ("meshtastic_mcp.uhubctl", "cycle"),
# USERPREFS
"userprefs_manifest": ("meshtastic_mcp.userprefs", "build_manifest"),
"userprefs_get": ("meshtastic_mcp.userprefs", "read_state"),
+7
View File
@@ -0,0 +1,7 @@
"""UI tier — input-broker-driven screen navigation tests.
Only runs when a screen-bearing role (esp32s3/heltec-v3) is present on the
hub AND the firmware was baked with `enable_ui_log=True` (so the
`Screen: frame N/M name=... reason=...` log lines are emitted). The
`tests/ui/conftest.py` fixture forces that bake stamp.
"""
+176
View File
@@ -0,0 +1,176 @@
"""Parse `Screen: frame N/M name=X reason=Y` log lines from `_debug_log_buffer`.
The firmware emits one line per frame transition when
`USERPREFS_UI_TEST_LOG` is defined (see src/graphics/Screen.cpp). Tests use
these helpers to assert which frame is shown / to wait for a transition to
settle before taking a camera capture.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from typing import Iterable, Iterator
FRAME_RE = re.compile(
r"Screen: frame (?P<idx>\d+)/(?P<count>\d+) name=(?P<name>\S+) reason=(?P<reason>\S+)"
)
@dataclass(frozen=True)
class FrameEvent:
idx: int
count: int
name: str
reason: str
raw: str
@classmethod
def parse(cls, line: str) -> "FrameEvent | None":
m = FRAME_RE.search(line)
if not m:
return None
return cls(
idx=int(m["idx"]),
count=int(m["count"]),
name=m["name"],
reason=m["reason"],
raw=line,
)
def iter_frame_events(lines: Iterable[str]) -> Iterator[FrameEvent]:
for line in lines:
evt = FrameEvent.parse(line)
if evt is not None:
yield evt
def get_current_frame(lines: list[str]) -> FrameEvent | None:
"""Return the most recent FrameEvent in `lines`, or None if none found."""
for line in reversed(lines):
evt = FrameEvent.parse(line)
if evt is not None:
return evt
return None
def wait_for_frame(
lines: list[str],
expected_name: str,
*,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
reason: str | None = None,
) -> FrameEvent:
"""Poll `lines` (the `_debug_log_buffer`) until a FrameEvent with
`name=expected_name` appears after the call started. Raises TimeoutError
with context if it doesn't arrive in `timeout_s`.
`reason` optionally filters to events matching a specific cause
(e.g. `"fn_f1"`, `"next"`, `"rebuild"`).
"""
start_idx = len(lines)
deadline = time.monotonic() + timeout_s
last: FrameEvent | None = None
while time.monotonic() < deadline:
# Scan only lines appended since we started waiting.
for line in lines[start_idx:]:
evt = FrameEvent.parse(line)
if evt is None:
continue
last = evt
if evt.name == expected_name and (reason is None or evt.reason == reason):
return evt
time.sleep(poll_interval_s)
seen = [e.name for e in iter_frame_events(lines[start_idx:])]
raise TimeoutError(
f"frame name={expected_name!r} reason={reason!r} not seen in {timeout_s}s; "
f"saw {len(seen)} transition(s): {seen!r}; last={last!r}"
)
def wait_for_any_frame(
lines: list[str],
*,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
) -> FrameEvent:
"""Wait for ANY frame transition to appear after call-start. Useful for
`no-op` tests that want to confirm a transition did NOT happen (via
TimeoutError) vs. one that did.
"""
start_idx = len(lines)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
for line in lines[start_idx:]:
evt = FrameEvent.parse(line)
if evt is not None:
return evt
time.sleep(poll_interval_s)
raise TimeoutError(f"no frame transition in {timeout_s}s")
def wait_for_reason(
lines: list[str],
reason: str,
*,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
) -> FrameEvent:
"""Wait for a frame event with `reason=<reason>` after call-start.
Matches only on `reason` — useful when the caller knows *why* a
transition should happen (e.g. `fn_f1`, `rebuild`) but not which named
frame the firmware will land on for this particular board.
"""
start_idx = len(lines)
deadline = time.monotonic() + timeout_s
last: FrameEvent | None = None
while time.monotonic() < deadline:
for line in lines[start_idx:]:
evt = FrameEvent.parse(line)
if evt is None:
continue
last = evt
if evt.reason == reason:
return evt
time.sleep(poll_interval_s)
raise TimeoutError(
f"no frame with reason={reason!r} in {timeout_s}s; last={last!r}"
)
def assert_no_frame_change(
lines: list[str],
*,
wait_s: float = 2.0,
) -> None:
"""Assert that NO new FrameEvent lines arrive within `wait_s`.
Used by idempotency / no-op tests (e.g. BACK on home frame).
"""
start_idx = len(lines)
time.sleep(wait_s)
new = [
e for e in (FrameEvent.parse(ln) for ln in lines[start_idx:]) if e is not None
]
if new:
raise AssertionError(
f"expected no frame change in {wait_s}s, but saw {len(new)} event(s): "
f"{[(e.reason, e.name) for e in new]!r}"
)
__all__ = [
"FRAME_RE",
"FrameEvent",
"assert_no_frame_change",
"get_current_frame",
"iter_frame_events",
"wait_for_any_frame",
"wait_for_frame",
"wait_for_reason",
]
+381
View File
@@ -0,0 +1,381 @@
"""UI-tier fixtures: camera lifecycle, OCR warmup, per-test frame capture,
and a `ui_home_state` autouse guard that resets to the home frame before
every test (prevents state bleed if a prior test exited inside a menu).
The camera + OCR modules live in `meshtastic_mcp/{camera,ocr}.py` (production
code, so the `capture_screen` MCP tool can share them). These fixtures wire
them into pytest + write per-test captures to `tests/ui_captures/…`.
"""
from __future__ import annotations
import re
import shutil
import time
from pathlib import Path
from typing import Any, Iterator
import pytest
from meshtastic_mcp import admin as admin_mod
from meshtastic_mcp import camera as camera_mod
from meshtastic_mcp import ocr as ocr_mod
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import FrameEvent, get_current_frame, wait_for_frame
# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3
# SSD1306) qualifies today — nrf52 (rak4631) has no display.
UI_CAPABLE_ROLES = ("esp32s3",)
# Where per-test captures land. One subdirectory per session seed, then per
# sanitized test nodeid — identical pattern to other pytest artifacts.
CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures"
def _sanitize_nodeid(nodeid: str) -> str:
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", nodeid)
# ---------- Role gating ----------------------------------------------------
@pytest.fixture
def ui_capable_role(request: pytest.FixtureRequest, hub_devices: dict[str, Any]) -> str:
"""Resolve the single role the UI tier drives.
Today that's `esp32s3`. Skips if the hub doesn't have one. A future
multi-screen hub could pick a role per parametrization.
"""
for role in UI_CAPABLE_ROLES:
if role in hub_devices:
return role
pytest.skip(
f"no UI-capable role on hub; need one of {UI_CAPABLE_ROLES} in {sorted(hub_devices)}"
)
@pytest.fixture
def ui_port(ui_capable_role: str, hub_devices: dict[str, Any]) -> str:
port = (
hub_devices[ui_capable_role].get("port")
if isinstance(hub_devices[ui_capable_role], dict)
else hub_devices[ui_capable_role]
)
if not port:
pytest.skip(f"{ui_capable_role!r} has no usable port")
return port
# ---------- Camera + OCR session fixtures ---------------------------------
@pytest.fixture(scope="session")
def camera(ui_capable_role_session: str | None) -> Iterator[camera_mod.CameraBackend]:
"""Session-scoped camera backend. Closed at teardown.
Backend + device selected by env vars (see `meshtastic_mcp.camera`).
Falls through to `NullBackend` when no camera is configured, so the
tests run end-to-end on machines without hardware; they just won't
have useful images.
"""
role = ui_capable_role_session or "esp32s3"
cam = camera_mod.get_camera(role)
try:
yield cam
finally:
cam.close()
@pytest.fixture(scope="session")
def ui_capable_role_session(hub_devices: dict[str, Any]) -> str | None:
"""Session-scoped lookup mirroring `ui_capable_role` but non-skipping.
Used by the `camera` session fixture so it doesn't depend on a
test-scoped skip.
"""
for role in UI_CAPABLE_ROLES:
if role in hub_devices:
return role
return None
@pytest.fixture(scope="session", autouse=True)
def _ocr_warm() -> None:
"""Pay easyocr's ~100 MB / cold-start cost ONCE per session.
Subsequent `ocr_text()` calls hit the cached reader and return quickly.
Swallows errors — if OCR isn't installed, warm is a no-op.
"""
try:
ocr_mod.warm()
except Exception: # noqa: BLE001 — belt: never block the suite on OCR init
pass
@pytest.fixture(scope="session")
def _ui_screen_kept_on(
ui_capable_role_session: str | None, hub_devices: dict[str, Any]
) -> Iterator[None]:
"""Keep the OLED on throughout the UI tier so input events aren't dropped.
Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122)
silently DROPS any event that arrives while the screen is off — it just
wakes the screen and returns. Every first event in each test would
disappear. We set `display.screen_on_secs = 86400` at session start
(effectively "always on" for the test window) and restore the prior
value at teardown.
"""
if ui_capable_role_session is None:
yield
return
hub_entry = hub_devices[ui_capable_role_session]
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
if not port:
yield
return
original: int | None = None
try:
current = admin_mod.get_config(section="display", port=port)
original = int(
current.get("config", {}).get("display", {}).get("screen_on_secs") or 0
)
except Exception: # noqa: BLE001
pass
try:
admin_mod.set_config("display.screen_on_secs", 86400, port=port)
# Send one wake event so the screen is actually ON going into the
# first test. The event itself gets dropped (screenWasOff), but the
# wake side-effect sticks.
try:
admin_mod.send_input_event(event_code=int(InputEventCode.FN_F1), port=port)
except Exception: # noqa: BLE001
pass
time.sleep(1.5) # Let the screen finish its wake transition.
except (
Exception
): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error
pass
try:
yield
finally:
if original is not None:
try:
admin_mod.set_config("display.screen_on_secs", original, port=port)
except Exception: # noqa: BLE001
pass
# ---------- Per-test capture + transcript ----------------------------------
class FrameCapture:
"""Per-test capture recorder. Created once per test via the
`frame_capture` fixture; call with a label to snapshot the screen.
"""
def __init__(
self,
cam: camera_mod.CameraBackend,
dir_path: Path,
lines: list[str],
nodeid: str,
) -> None:
self._cam = cam
self._dir = dir_path
self._lines = lines
self._nodeid = nodeid
self._step = 0
self.captures: list[dict[str, Any]] = []
self._transcript_path = dir_path / "transcript.md"
self._dir.mkdir(parents=True, exist_ok=True)
self._transcript_path.write_text(
f"# {nodeid} — {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
encoding="utf-8",
)
def __call__(self, label: str) -> dict[str, Any]:
self._step += 1
stem = f"{self._step:03d}-{re.sub(r'[^a-zA-Z0-9_-]+', '-', label)}"
png_path = self._dir / f"{stem}.png"
ocr_path = self._dir / f"{stem}.ocr.txt"
try:
png = self._cam.capture()
except Exception as exc: # noqa: BLE001
png = b""
ocr_str = f"[capture error: {exc}]"
else:
camera_mod.save_capture(png, png_path)
try:
ocr_str = ocr_mod.ocr_text(png)
except Exception as exc: # noqa: BLE001
ocr_str = f"[ocr error: {exc}]"
ocr_path.write_text(ocr_str or "", encoding="utf-8")
frame = get_current_frame(self._lines)
entry: dict[str, Any] = {
"step": self._step,
"label": label,
"png_path": str(png_path) if png else None,
"ocr_text": ocr_str,
"frame": (
{
"idx": frame.idx,
"name": frame.name,
"reason": frame.reason,
}
if frame is not None
else None
),
}
self.captures.append(entry)
with self._transcript_path.open("a", encoding="utf-8") as fh:
frame_str = (
f"frame {frame.idx}/{frame.count} name={frame.name} reason={frame.reason}"
if frame is not None
else "frame <none>"
)
ocr_summary = (ocr_str or "").replace("\n", " / ")[:80]
fh.write(
f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n"
)
return entry
@pytest.fixture
def frame_capture(
request: pytest.FixtureRequest,
camera: camera_mod.CameraBackend,
session_seed: str,
) -> Iterator[FrameCapture]:
nodeid = _sanitize_nodeid(request.node.nodeid)
dir_path = CAPTURES_ROOT / session_seed / nodeid
# Fresh directory per test run so reruns don't mix old and new images.
if dir_path.exists():
shutil.rmtree(dir_path)
lines = getattr(request.node, "_debug_log_buffer", [])
fc = FrameCapture(camera, dir_path, lines, nodeid)
# Stash so pytest_runtest_makereport can embed captures in HTML extras.
request.node._ui_captures = fc.captures # type: ignore[attr-defined]
yield fc
# ---------- Pre-test home-state reset --------------------------------------
def _send_event(port: str, event: InputEventCode) -> None:
try:
admin_mod.send_input_event(event_code=int(event), port=port)
except Exception: # noqa: BLE001
# Treat a failed event as soft — the subsequent frame-log assertion
# surfaces the real problem with better context.
pass
@pytest.fixture(autouse=True)
def ui_home_state(
request: pytest.FixtureRequest,
hub_devices: dict[str, Any],
_ui_screen_kept_on: None,
) -> Iterator[None]:
"""Before every UI test, jump to frame 0 (usually `home`) via FN_F1 and
confirm the device emitted the expected frame log.
Why FN_F1 (not BACK): FN_F1 maps to `switchToFrame(0)` and ALWAYS
produces a `reason=fn_f1` log line, regardless of whatever frame the
prior test left us on. BACK is context-sensitive (dismisses overlays
on some frames, no-op on others) and can silently fail to transition.
This fixture doubles as the macro-presence detector: if no `fn_f1`
log arrives within 5 s, the firmware almost certainly wasn't baked
with `USERPREFS_UI_TEST_LOG`. Skip the tier with an actionable hint
instead of letting every test body fail with a confusing assertion.
Autouse scope is restricted to `tests/ui/` by virtue of this fixture
living in that directory's conftest.py — no explicit nodeid guard
needed (and earlier attempts at one were wrong, matching `/tests/ui/`
against a nodeid that has no leading slash).
"""
role = next((r for r in UI_CAPABLE_ROLES if r in hub_devices), None)
if role is None:
yield
return
hub_entry = hub_devices[role]
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
lines: list[str] = getattr(request.node, "_debug_log_buffer", [])
start_len = len(lines)
# First: a wake event. The screen should already be kept on by
# `_ui_screen_kept_on`, but belt + suspenders — if it somehow
# powered off (sleep after factory_reset, etc.), this first FN_F1
# gets dropped by InputBroker's screenWasOff guard. That's fine;
# the second FN_F1 below lands cleanly.
_send_event(port, InputEventCode.FN_F1)
time.sleep(0.4)
_send_event(port, InputEventCode.FN_F1)
# Wait for the fn_f1 transition log. Any new `reason=fn_f1` line
# after call-start counts — we don't care about the name (it should
# be `home` or `deviceFocused` depending on board-specific frame order).
from ._screen_log import wait_for_reason
try:
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
except TimeoutError:
# One more try — FreeRTOS queue may be draining slowly.
_send_event(port, InputEventCode.FN_F1)
try:
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
except TimeoutError:
# Look at what the _debug_log_buffer actually contains to
# disambiguate "macro off" from "macro on but event lost".
frame_lines = [ln for ln in lines[start_len:] if "Screen: frame" in ln]
processing_lines = [
ln for ln in lines[start_len:] if "Processing input event" in ln
]
if frame_lines:
pytest.skip(
f"ui_home_state: events fire but none reach Screen "
f"(saw {len(frame_lines)} frame line(s), "
f"{len(processing_lines)} admin inject(s)). "
f"Device may be in an unusual state — try `--force-bake`."
)
else:
pytest.skip(
"ui_home_state: no `Screen: frame` log after FN_F1. "
"Firmware not baked with USERPREFS_UI_TEST_LOG — "
"run with `--force-bake` to reflash, or verify the "
"macro is active in the bake."
)
yield
# ---------- Small helpers reused by tests ---------------------------------
def send_event(
port: str, event: InputEventCode | int | str, **kwargs: Any
) -> dict[str, Any]:
"""Thin wrapper so tests read `send_event(port, InputEventCode.RIGHT)`."""
return admin_mod.send_input_event(event_code=event, port=port, **kwargs)
__all__ = [
"FrameCapture",
"UI_CAPABLE_ROLES",
"send_event",
"wait_for_frame",
"FrameEvent",
]
# Make the helpers discoverable to test modules via `from .conftest import …`.
# pytest auto-loads conftest.py, but the symbols above are also re-exported
# for readability in the test files.
+61
View File
@@ -0,0 +1,61 @@
"""FN_F1..F5 directly jumps to frame 0..4 via Screen::handleInputEvent.
Parametrized over the 5 function keys. Each expects a
`Screen: frame <idx>/<count> name=... reason=fn_f<k>` log line, with
`idx == k-1`. We don't hardcode the frame *name* because the layout
depends on which modules are compiled in for this board.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_reason
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(120)
@pytest.mark.parametrize(
"event,expected_idx,reason",
[
(InputEventCode.FN_F1, 0, "fn_f1"),
(InputEventCode.FN_F2, 1, "fn_f2"),
(InputEventCode.FN_F3, 2, "fn_f3"),
(InputEventCode.FN_F4, 3, "fn_f4"),
(InputEventCode.FN_F5, 4, "fn_f5"),
],
ids=["FN_F1", "FN_F2", "FN_F3", "FN_F4", "FN_F5"],
)
def test_fn_jump_direct_frame(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
event: InputEventCode,
expected_idx: int,
reason: str,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
assert start.name in (
"home",
"deviceFocused",
), f"setup expected frame 0 landing, got {start.name!r}"
frame_capture("initial")
if start.count <= expected_idx:
pytest.skip(
f"device has {start.count} frames; FN_F{expected_idx + 1} needs > {expected_idx}"
)
send_event(ui_port, event)
time.sleep(0.1)
evt = wait_for_reason(lines, reason, timeout_s=5.0)
assert evt.idx == expected_idx, (
f"FN_F{expected_idx + 1} expected idx={expected_idx}, got {evt.idx} "
f"(name={evt.name}, count={evt.count})"
)
frame_capture(f"after-{reason}")
+61
View File
@@ -0,0 +1,61 @@
"""Out-of-bounds FN_F5 when the device has <5 frames: no crash, idx unchanged.
`Screen::handleInputEvent` dispatches FN_F5 unconditionally to
`ui->switchToFrame(4)`. The OLEDDisplayUi library typically clamps or
silently ignores out-of-range indices, but firmware bugs have existed
here — this test protects against a regression that would wedge the UI.
If this test fails, first check: did the device actually crash (Guru
Meditation in the log)? Or did switchToFrame accept an OOB index and
leave the UI blank?
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_reason
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(90)
def test_fn_f5_out_of_bounds(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None
if start.count > 5:
pytest.skip(
f"device has {start.count} frames; FN_F5 is in-bounds — not testing OOB here"
)
frame_capture("initial-home")
send_event(ui_port, InputEventCode.FN_F5)
time.sleep(0.5)
try:
wait_for_reason(lines, "fn_f5", timeout_s=3.0)
except TimeoutError:
# Firmware may have ignored the event entirely — acceptable.
pass
# Capture whatever is on screen (OCR will tell us if something weird
# happened). Device must remain responsive — subsequent events should
# still land.
frame_capture("after-fn_f5-oob")
# Send a RIGHT to confirm the UI is still alive. If this times out,
# the OOB switchToFrame wedged the UI.
send_event(ui_port, InputEventCode.RIGHT)
post = wait_for_reason(lines, "next", timeout_s=5.0)
assert (
post is not None
), "UI wedged after OOB FN_F5 — RIGHT no longer produces frame log"
frame_capture("after-recovery-right")
+68
View File
@@ -0,0 +1,68 @@
"""SELECT on the home frame opens the home menu; BACK closes it.
The home menu is an overlay (menuHandler::homeBaseMenu), not a frame
transition — so we verify via OCR difference between before/after
captures rather than a `Screen: frame` log line. The underlying
mechanism is still InputBroker → Screen::handleInputEvent → menu
callback.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(120)
def test_select_opens_home_menu(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None
if start.name not in ("home", "deviceFocused"):
pytest.skip(
f"SELECT on {start.name!r} doesn't open homeBaseMenu; "
"test is only valid when the landing frame is home/deviceFocused"
)
initial = frame_capture("initial")
send_event(ui_port, InputEventCode.SELECT)
time.sleep(0.8)
opened = frame_capture("after-select")
# The menu is an overlay (not a frame change). We cannot use log
# assertion — instead, OCR should differ because a menu list is now
# drawn on top.
initial_text = (initial.get("ocr_text") or "").strip()
opened_text = (opened.get("ocr_text") or "").strip()
if initial_text and opened_text:
# When OCR is available, require *some* difference between the two
# frames — even a single menu title changes the transcribed text.
assert initial_text != opened_text, (
f"expected OCR diff after SELECT; both read {initial_text!r}. "
"If both are empty, check camera alignment + OCR backend."
)
# Back out — the menu dismisses on BACK.
send_event(ui_port, InputEventCode.BACK)
time.sleep(0.8)
closed = frame_capture("after-back")
# Soft check: OCR after BACK should look different from the menu
# (either back to home or onto a previous frame — BACK's exact
# behavior when the menu is up vs. not-up varies). We don't assert
# equality because OLED rendering is pixel-stable but camera sampling
# introduces noise.
if opened_text and closed.get("ocr_text"):
close_text = (closed.get("ocr_text") or "").strip()
assert (
close_text != opened_text
), f"after BACK, OCR still looks like the menu: {close_text!r}"
@@ -0,0 +1,60 @@
"""Once we navigate to the textMessage frame, UP/DOWN exercises the
message-scroll path (or opens CannedMessages on empty devices).
Weaker than a "no frame change" assertion because on a fresh bench
device the message store is usually empty, and the firmware's UP
handler in that case launches CannedMessage — which DOES rebuild
frames. We just verify the path doesn't crash + produce captures for
visual inspection.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(180)
def test_up_down_on_textmessage_survives(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
frame_capture("initial")
# Walk RIGHT until we land on textMessage — up to 15 hops.
for _i in range(15):
send_event(ui_port, InputEventCode.RIGHT)
time.sleep(0.3)
current = get_current_frame(lines)
if current is not None and current.name == "textMessage":
break
else:
pytest.skip(
"couldn't reach textMessage frame within 15 RIGHTs — not present on this board"
)
wait_for_frame(lines, "textMessage", timeout_s=5.0)
frame_capture("on-textMessage")
# UP and DOWN exercise the message-scroll / canned-message-launch path.
# Capture after each so the HTML report shows any visual effect.
send_event(ui_port, InputEventCode.UP)
time.sleep(0.3)
frame_capture("after-up")
send_event(ui_port, InputEventCode.DOWN)
time.sleep(0.3)
frame_capture("after-down")
# Soft check: we should still be in a reachable frame (not wedged).
# The next test's `ui_home_state` will error out if the device is
# unresponsive, so we don't need a stricter guarantee here.
final = get_current_frame(lines)
assert final is not None, "no frame log after UP/DOWN — event path broke"
@@ -0,0 +1,93 @@
"""INPUT_BROKER_RIGHT cycles forward through frames; INPUT_BROKER_LEFT backs.
The simplest UI test: fire N RIGHT events and assert the frame index
moves forward by N (modulo frameCount). Each step captures an image +
OCR for the HTML report.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(120)
def test_input_right_cycles_frames(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
# FN_F1 in ui_home_state lands on frame 0. The name at frame 0 varies
# by board (home on heltec-v3, deviceFocused on others) — accept either.
assert start.name in (
"home",
"deviceFocused",
), f"setup expected home/deviceFocused at frame 0, got {start.name!r}"
frame_capture("initial")
visited = [start.idx]
for step in range(4):
send_event(ui_port, InputEventCode.RIGHT)
# Each RIGHT should bump the frame index by 1. The log fires with
# `reason=next` from showFrame(NEXT).
before_count = len(list(_frame_events(lines)))
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if len(list(_frame_events(lines))) > before_count:
break
time.sleep(0.1)
evt = get_current_frame(lines)
assert evt is not None
assert (
evt.reason == "next"
), f"step {step}: expected reason=next, got {evt.reason!r}"
visited.append(evt.idx)
frame_capture(f"after-right-{step + 1}")
# Sanity: each index should differ from its predecessor.
diffs = [visited[i + 1] - visited[i] for i in range(len(visited) - 1)]
assert all(
d in (1, -(start.count - 1)) for d in diffs
), f"expected monotonic +1 steps (or a wrap), got visited={visited} diffs={diffs}"
@pytest.mark.timeout(120)
def test_input_left_returns_to_home(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
"""After RIGHT×3 + LEFT×3, we should end up back on the starting frame."""
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None
start_name = start.name
frame_capture("initial")
for _ in range(3):
send_event(ui_port, InputEventCode.RIGHT)
time.sleep(0.3)
frame_capture("after-right-3")
for _ in range(3):
send_event(ui_port, InputEventCode.LEFT)
time.sleep(0.3)
# Back to whichever frame we started on (home or deviceFocused).
wait_for_frame(lines, start_name, timeout_s=5.0)
frame_capture(f"after-left-3-back-{start_name}")
def _frame_events(lines: list[str]) -> Any:
from ._screen_log import iter_frame_events
return iter_frame_events(lines)
@@ -0,0 +1,51 @@
"""On the nodelist_nodes frame, UP/DOWN scrolls the list via
`NodeListRenderer::scrollUp/scrollDown` (src/graphics/Screen.cpp:1779-1788).
The firmware returns 0 before notifying observers, so no frame-change
log fires. Verify the path doesn't crash and we stay on nodelist_nodes.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import assert_no_frame_change, get_current_frame, wait_for_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(180)
def test_up_down_on_nodelist_no_frame_change(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
frame_capture("initial")
# Walk RIGHT until we land on nodelist_nodes.
for _i in range(15):
send_event(ui_port, InputEventCode.RIGHT)
time.sleep(0.3)
current = get_current_frame(lines)
if current is not None and current.name == "nodelist_nodes":
break
else:
pytest.skip("couldn't reach nodelist_nodes within 15 RIGHTs")
wait_for_frame(lines, "nodelist_nodes", timeout_s=5.0)
frame_capture("on-nodelist")
# UP/DOWN on nodelist scroll internally + `return 0` before
# notifyObservers — no frame-change log. Verify.
send_event(ui_port, InputEventCode.UP)
assert_no_frame_change(lines, wait_s=1.5)
send_event(ui_port, InputEventCode.DOWN)
assert_no_frame_change(lines, wait_s=1.5)
final = get_current_frame(lines)
assert (
final is not None and final.name == "nodelist_nodes"
), f"UP/DOWN moved us off nodelist_nodes; now on {final!r}"
frame_capture("after-up-down")
@@ -0,0 +1,90 @@
"""Pin `InputEventCode` values to the firmware `input_broker_event` enum.
If this test fails, someone changed the firmware enum (or this Python
mirror) and they must stay in sync — the admin RPC sends these as u8
wire values directly.
Also exercises `coerce_event_code` for the happy + error paths.
"""
from __future__ import annotations
import pytest
from meshtastic_mcp.input_events import InputEventCode, coerce_event_code
class TestInputEventCodeValues:
"""These values MUST match src/input/InputBroker.h exactly."""
def test_navigation_keys(self) -> None:
assert int(InputEventCode.UP) == 17
assert int(InputEventCode.DOWN) == 18
assert int(InputEventCode.LEFT) == 19
assert int(InputEventCode.RIGHT) == 20
def test_action_keys(self) -> None:
assert int(InputEventCode.SELECT) == 10
assert int(InputEventCode.CANCEL) == 24
assert int(InputEventCode.BACK) == 27
def test_long_press_variants(self) -> None:
assert int(InputEventCode.SELECT_LONG) == 11
assert int(InputEventCode.UP_LONG) == 12
assert int(InputEventCode.DOWN_LONG) == 13
def test_fn_keys(self) -> None:
assert int(InputEventCode.FN_F1) == 0xF1
assert int(InputEventCode.FN_F2) == 0xF2
assert int(InputEventCode.FN_F3) == 0xF3
assert int(InputEventCode.FN_F4) == 0xF4
assert int(InputEventCode.FN_F5) == 0xF5
def test_system_events(self) -> None:
assert int(InputEventCode.SHUTDOWN) == 0x9B
assert int(InputEventCode.GPS_TOGGLE) == 0x9E
assert int(InputEventCode.SEND_PING) == 0xAF
def test_auto_increment_block(self) -> None:
# C enum: `BACK = 27, USER_PRESS, ALT_PRESS, ALT_LONG` → 28, 29, 30.
assert int(InputEventCode.USER_PRESS) == 28
assert int(InputEventCode.ALT_PRESS) == 29
assert int(InputEventCode.ALT_LONG) == 30
class TestCoerceEventCode:
def test_int_passthrough(self) -> None:
assert coerce_event_code(20) == 20
assert coerce_event_code(0) == 0
assert coerce_event_code(255) == 255
def test_enum_passthrough(self) -> None:
assert coerce_event_code(InputEventCode.RIGHT) == 20
assert coerce_event_code(InputEventCode.FN_F1) == 0xF1
def test_name_case_insensitive(self) -> None:
assert coerce_event_code("right") == 20
assert coerce_event_code("RIGHT") == 20
assert coerce_event_code("Right") == 20
def test_input_broker_prefix_stripped(self) -> None:
assert coerce_event_code("INPUT_BROKER_FN_F1") == 0xF1
assert coerce_event_code("input_broker_select") == 10
def test_hyphen_and_underscore_equivalence(self) -> None:
assert coerce_event_code("fn-f1") == 0xF1
def test_int_out_of_range_raises(self) -> None:
with pytest.raises(ValueError, match="u8"):
coerce_event_code(256)
with pytest.raises(ValueError, match="u8"):
coerce_event_code(-1)
def test_unknown_name_raises(self) -> None:
with pytest.raises(ValueError, match="unknown event code name"):
coerce_event_code("NOT_A_KEY")
def test_wrong_type_raises(self) -> None:
with pytest.raises(TypeError):
coerce_event_code(1.5) # type: ignore[arg-type]
with pytest.raises(TypeError):
coerce_event_code(None) # type: ignore[arg-type]
@@ -0,0 +1,148 @@
"""Pin the `uhubctl` default-output parser against canned real-world samples.
uhubctl's output format has been stable since v2.x but occasionally adds
new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose
regexes to tolerate additions; this test keeps us honest.
Samples captured from:
- v2.6.0 on macOS (Homebrew) — two USB2 hubs, one populated with an
nRF52 and a CP2102, plus chained USB3 hubs.
- v2.5.0 on Linux (hypothetical — reconstructed from the project README).
"""
from __future__ import annotations
import pytest
from meshtastic_mcp.uhubctl import (
ROLE_VIDS,
UhubctlError,
parse_list_output,
)
# Actual `uhubctl` stdout on the developer's macOS bench, Apr 2026.
_SAMPLE_MACOS_V26 = """\
Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
Port 1: 0100 power
Port 2: 0103 power enable connect [239a:8029 RAKwireless WisCore RAK4631 Board 920456B1E6972262]
Port 3: 0103 power enable connect [10c4:ea60 Silicon Labs CP2102 USB to UART Bridge Controller 0001]
Port 4: 0100 power
Current status for hub 1-2.3 [2109:0817 VIA Labs, Inc. USB3.0 Hub, USB 3.10, 4 ports, ppps]
Port 1: 02a0 power 5gbps Rx.Detect
Port 2: 02a0 power 5gbps Rx.Detect
Port 3: 02a0 power 5gbps Rx.Detect
Port 4: 02a0 power 5gbps Rx.Detect
Current status for hub 1-1 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
Port 1: 0100 power
Port 2: 0100 power
Port 3: 0503 power highspeed enable connect [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
Port 4: 0100 power
"""
# Minimal Linux-style sample (fewer hubs, shows a non-PPPS hub).
_SAMPLE_LINUX_NONPPPS = """\
Current status for hub 2-1.4 [05e3:0608 GenesysLogic USB2.1 Hub, USB 2.10, 4 ports]
Port 1: 0507 power highspeed suspend enable connect [239a:0029 Adafruit Feather Bootloader]
Port 2: 0100 power
Port 3: 0100 power
Port 4: 0100 power
"""
class TestParseListOutput:
def test_parses_macos_sample_hub_count(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
assert len(hubs) == 3
def test_parses_hub_location_and_vid(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
via_hub = hubs[0]
assert via_hub["location"] == "1-1.3"
assert via_hub["vid"] == 0x2109
assert via_hub["pid"] == 0x2817
assert via_hub["ppps"] is True
def test_parses_port_with_device(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
nrf52_hub = hubs[0]
port2 = next(p for p in nrf52_hub["ports"] if p["port"] == 2)
assert port2["device_vid"] == 0x239A
assert port2["device_pid"] == 0x8029
assert "RAKwireless" in port2["device_desc"]
def test_empty_port_has_no_device(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
nrf52_hub = hubs[0]
port1 = next(p for p in nrf52_hub["ports"] if p["port"] == 1)
assert port1["device_vid"] is None
assert port1["device_pid"] is None
assert port1["device_desc"] is None
def test_ports_count(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
for hub in hubs:
assert len(hub["ports"]) == 4 # each sample hub has 4 ports
def test_non_ppps_hub_flagged(self) -> None:
hubs = parse_list_output(_SAMPLE_LINUX_NONPPPS)
assert len(hubs) == 1
assert hubs[0]["ppps"] is False
def test_handles_empty_input(self) -> None:
assert parse_list_output("") == []
def test_handles_malformed_lines_gracefully(self) -> None:
# Lines that don't match HUB_RE or PORT_RE are ignored silently.
garbage = "uhubctl: warning: something weird\n" + _SAMPLE_LINUX_NONPPPS
hubs = parse_list_output(garbage)
assert len(hubs) == 1
class TestRoleVids:
def test_nrf52_mapped(self) -> None:
assert 0x239A in ROLE_VIDS["nrf52"]
def test_esp32s3_covers_both_vids(self) -> None:
# Espressif native USB + CP2102 USB-UART on heltec-v3 boards.
assert 0x303A in ROLE_VIDS["esp32s3"]
assert 0x10C4 in ROLE_VIDS["esp32s3"]
class TestResolveTargetErrorPaths:
def test_unknown_role_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
from meshtastic_mcp.uhubctl import resolve_target
# Clear any env-var pinning that might make this pass accidentally.
for key in (
"MESHTASTIC_UHUBCTL_LOCATION_FLUX",
"MESHTASTIC_UHUBCTL_PORT_FLUX",
):
monkeypatch.delenv(key, raising=False)
with pytest.raises(UhubctlError, match="unknown role"):
resolve_target("flux")
def test_invalid_env_port_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
from meshtastic_mcp.uhubctl import resolve_target
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "1-1.3")
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "not-an-int")
with pytest.raises(UhubctlError, match="not a valid integer"):
resolve_target("nrf52")
def test_env_var_pinning_wins(self, monkeypatch: pytest.MonkeyPatch) -> None:
from meshtastic_mcp.uhubctl import resolve_target
# Env-var pinning should NOT require uhubctl to be running / installed.
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "9-9.9")
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "7")
assert resolve_target("nrf52") == ("9-9.9", 7)
def test_normalize_role_strips_alt_suffix(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from meshtastic_mcp.uhubctl import resolve_target
# esp32s3_alt collapses to esp32s3 for env-var lookup.
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_ESP32S3", "2-2")
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_ESP32S3", "3")
assert resolve_target("esp32s3_alt") == ("2-2", 3)
@@ -0,0 +1,80 @@
"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass.
The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`;
if the format string changes, this test — and the parser in
`tests/ui/_screen_log.py` — have to be updated together.
"""
from __future__ import annotations
from tests.ui._screen_log import FRAME_RE, FrameEvent, iter_frame_events
class TestFrameEventParse:
def test_exact_firmware_output(self) -> None:
raw = "Screen: frame 2/8 name=home reason=next"
evt = FrameEvent.parse(raw)
assert evt is not None
assert evt.idx == 2
assert evt.count == 8
assert evt.name == "home"
assert evt.reason == "next"
assert evt.raw == raw
def test_with_log_prefix(self) -> None:
"""Log lines may be preamble-wrapped by the firmware LOG_INFO macro
(timestamp, severity, etc.) — the regex uses .search() not .match()
so prefixes are fine."""
raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 "
evt = FrameEvent.parse(raw)
assert evt is not None
assert evt.idx == 4
assert evt.count == 12
assert evt.name == "nodelist_nodes"
assert evt.reason == "fn_f3"
def test_rebuild_reason(self) -> None:
evt = FrameEvent.parse("Screen: frame 0/5 name=deviceFocused reason=rebuild")
assert evt is not None
assert evt.reason == "rebuild"
def test_all_fn_reasons(self) -> None:
for k in range(1, 6):
evt = FrameEvent.parse(
f"Screen: frame {k - 1}/8 name=settings reason=fn_f{k}"
)
assert evt is not None and evt.reason == f"fn_f{k}"
def test_unknown_name_is_preserved(self) -> None:
"""If the reverse-map returns 'unknown', that still parses cleanly."""
evt = FrameEvent.parse("Screen: frame 99/100 name=unknown reason=prev")
assert evt is not None and evt.name == "unknown"
def test_non_matching_line_returns_none(self) -> None:
assert FrameEvent.parse("BOOT Booting firmware 2.7.23") is None
assert FrameEvent.parse("") is None
assert FrameEvent.parse("Screen: without the right format") is None
class TestIterFrameEvents:
def test_filters_non_matching_lines(self) -> None:
lines = [
"Booting...",
"Screen: frame 1/5 name=home reason=rebuild",
"Some other log line",
"Screen: frame 2/5 name=textMessage reason=next",
]
evts = list(iter_frame_events(lines))
assert len(evts) == 2
assert evts[0].reason == "rebuild"
assert evts[1].reason == "next"
class TestRegexAnchoring:
def test_regex_is_compiled(self) -> None:
assert FRAME_RE.search("Screen: frame 0/0 name=home reason=next") is not None
def test_regex_allows_unusual_names(self) -> None:
r"""Name is `\S+`, so compound names with underscores/digits match."""
m = FRAME_RE.search("Screen: frame 5/10 name=nodelist_hopsignal reason=fn_f2")
assert m is not None and m["name"] == "nodelist_hopsignal"
+3 -2
View File
@@ -29,6 +29,7 @@ build_flags = -Wno-missing-field-initializers
-DUSE_THREAD_NAMES
-DTINYGPS_OPTION_NO_CUSTOM_FIELDS
-DPB_ENABLE_MALLOC=1
-DPB_VALIDATE_UTF8=1
-DRADIOLIB_EXCLUDE_CC1101=1
-DRADIOLIB_EXCLUDE_NRF24=1
-DRADIOLIB_EXCLUDE_RF69=1
@@ -66,7 +67,7 @@ monitor_speed = 115200
monitor_filters = direct
lib_deps =
# renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/21e484f409cde18d44012caef84c244eb5ca28f3.zip
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip
# renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master
https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip
# renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master
@@ -124,7 +125,7 @@ lib_deps =
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
https://github.com/meshtastic/device-ui/archive/5305670b68eb5b92d14e62b5b536969ca4bb441f.zip
https://github.com/meshtastic/device-ui/archive/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip
; Common libs for environmental measurements in telemetry module
[environmental_base]
+148 -82
View File
@@ -94,16 +94,31 @@ static const adc_atten_t atten = ADC_ATTENUATION;
#endif
#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
@@ -469,28 +484,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
@@ -511,9 +512,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
@@ -646,14 +647,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
@@ -746,37 +743,17 @@ bool Power::setup()
found = true;
#endif
}
#ifdef EXT_PWR_DETECT
attachInterrupt(
EXT_PWR_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef BATTERY_CHARGING_INV
attachInterrupt(
BATTERY_CHARGING_INV,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef EXT_CHRG_DETECT
attachInterrupt(
EXT_CHRG_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
},
CHANGE);
#endif
attachPowerInterrupts();
enabled = found;
low_voltage_counter = 0;
#ifdef ARCH_ESP32
// Register callbacks for before and after lightsleep
// Used to detach and reattach interrupts
lsObserver.observe(&notifyLightSleep);
lsEndObserver.observe(&notifyLightSleepEnd);
#endif
return found;
}
@@ -804,8 +781,10 @@ void Power::reboot()
rp2040.reboot();
#elif defined(ARCH_PORTDUINO)
deInitApiServer();
#ifdef __linux__
if (aLinuxInputImpl)
aLinuxInputImpl->deInit();
#endif
SPI.end();
Wire.end();
Serial1.end();
@@ -1023,6 +1002,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...
@@ -1039,13 +1026,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();
}
@@ -1055,6 +1035,97 @@ int32_t Power::runOnce()
return (statusHandler && statusHandler->isInitialized()) ? (1000 * 20) : RUN_SAME;
}
#ifdef ARCH_ESP32
// Detach our class' interrupts before lightsleep
// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press
int Power::beforeLightSleep(void *unused)
{
LOG_WARN("Detaching power interrupts for sleep");
detachPowerInterrupts();
return 0; // Indicates success
}
// Reconfigure our interrupts
// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep
int Power::afterLightSleep(esp_sleep_wakeup_cause_t cause)
{
attachPowerInterrupts();
return 0; // Indicates success
}
#endif
/*
* Attach (or re-attach) hardware interrupts for power management
* Public method. Used outside class when waking from MCU sleep
*/
void Power::attachPowerInterrupts()
{
#ifdef EXT_PWR_DETECT
attachInterrupt(
EXT_PWR_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef BATTERY_CHARGING_INV
attachInterrupt(
BATTERY_CHARGING_INV,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef EXT_CHRG_DETECT
attachInterrupt(
EXT_CHRG_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
},
CHANGE);
#endif
#ifdef PMU_IRQ
if (PMU) {
attachInterrupt(
PMU_IRQ,
[]() {
pmu_irq = true;
power->setIntervalFromNow(0);
runASAP = true;
},
FALLING);
}
#endif
}
/*
* Detach the "normal" button interrupts.
* Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep
*/
void Power::detachPowerInterrupts()
{
#ifdef EXT_PWR_DETECT
detachInterrupt(EXT_PWR_DETECT);
#endif
#ifdef BATTERY_CHARGING_INV
detachInterrupt(BATTERY_CHARGING_INV);
#endif
#ifdef EXT_CHRG_DETECT
detachInterrupt(EXT_CHRG_DETECT);
#endif
#ifdef PMU_IRQ
if (PMU) {
detachInterrupt(PMU_IRQ);
}
#endif
}
/**
* Init the power manager chip
*
@@ -1326,21 +1397,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);
attachInterrupt(
PMU_IRQ, [] { pmu_irq = true; }, FALLING);
// 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();
@@ -1806,7 +1872,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;
@@ -1815,7 +1881,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
@@ -1837,10 +1903,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
View File
@@ -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");
+1 -1
View File
@@ -1,8 +1,8 @@
#pragma once
#include "../freertosinc.h"
#include "Print.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <Print.h>
#include <stdarg.h>
#include <string>
+2 -2
View File
@@ -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.
*/
+1
View File
@@ -499,6 +499,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
+5 -1
View File
@@ -31,6 +31,10 @@ class ScanI2C
INA3221,
MAX17048,
MCP9808,
SHT31,
SHT4X,
SHTC3,
SHTXX,
LPS22HB,
QMC6310U,
QMC6310N,
@@ -86,13 +90,13 @@ class ScanI2C
DA217,
CHSC6X,
CST226SE,
CST3530,
BMI270,
SEN5X,
SFA30,
CW2015,
SCD30,
ADS1115,
SHTXX
} DeviceType;
// typedef uint8_t DeviceAddress;
+50 -11
View File
@@ -415,30 +415,45 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
#if !defined(M5STACK_UNITC6L)
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;
}
}
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
} else if (detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
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
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);
@@ -629,7 +644,31 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(SCD30_ADDR, SCD30, "SCD30", (uint8_t)addr.address);
case CST328_ADDR:
// Do we have the CST328 or the CST226SE
// Do we have the CST328 or the CST226SE,CST3530
{
// T-Deck pro V1.1 new touch panel use CST3530
int retry = 5;
while (retry--) {
uint8_t buffer[7];
uint8_t r_cmd[] = {0x0d0, 0x03, 0x00, 0x00};
i2cBus->beginTransmission(addr.address);
i2cBus->write(r_cmd, sizeof(r_cmd));
if (i2cBus->endTransmission() == 0) {
i2cBus->requestFrom((int)addr.address, 7);
i2cBus->readBytes(buffer, 7);
if (buffer[2] == 0xCA && buffer[3] == 0xCA) {
logFoundDevice("CST3530", (uint8_t)addr.address);
type = CST3530;
break;
}
}
uint8_t cmd1[] = {0xD0, 0x00, 0x04, 0x00};
i2cBus->beginTransmission(addr.address);
i2cBus->write(cmd1, sizeof(cmd1));
i2cBus->endTransmission();
delay(50);
}
}
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xAB), 1);
if (registerValue == 0xA9) {
type = CST226SE;
+6 -1
View File
@@ -104,8 +104,13 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit)
// End the update process - virtual method, overridden in derived class
void EInkDisplay::endUpdate()
{
// Power off display hardware, then deep-sleep (Except Wireless Paper V1.1, no deep-sleep)
#ifndef EINK_NOT_HIBERNATE
// By default, power off the E-Ink display hardware and enter hibernate().
// Boards/panels that define EINK_NOT_HIBERNATE intentionally skip this step.
// Skipping hibernate() can help avoid panel-specific wake/refresh or ghosting issues,
// but it typically trades lower power savings for that compatibility.
adafruitDisplay->hibernate();
#endif
}
// Write the buffer to the display memory
+144 -53
View File
@@ -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,6 +55,7 @@ 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"
@@ -69,12 +71,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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
@@ -109,6 +105,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
@@ -227,7 +244,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
@@ -249,7 +266,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
@@ -273,7 +290,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
@@ -296,7 +313,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
@@ -388,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);
@@ -474,9 +467,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);
@@ -663,16 +660,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
@@ -718,7 +715,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);
@@ -757,9 +754,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();
@@ -832,7 +829,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
@@ -903,9 +900,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;
}
@@ -996,7 +993,7 @@ 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
@@ -1058,7 +1055,7 @@ void Screen::setSSLFrames()
// LOG_DEBUG("Show SSL frames");
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
ui->setFrames(sslFrames, 1);
ui->update();
updateUiFrame(ui);
}
}
@@ -1094,7 +1091,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)
@@ -1357,6 +1354,10 @@ void Screen::setFrames(FrameFocus focus)
// Store the info about this frameset, for future setFrames calls
this->framesetInfo = fsi;
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("rebuild", ui->getUiState()->currentFrame);
#endif
setFastFramerate(); // Draw ASAP
}
@@ -1465,9 +1466,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;
@@ -1511,11 +1518,77 @@ void Screen::handleOnPress()
}
}
#ifdef USERPREFS_UI_TEST_LOG
void Screen::logFrameChange(const char *reason, uint8_t targetIdx)
{
// Reverse-map an index to a stable name string keyed off FramePositions
// field names — so the pytest harness can assert `name=nodelist_nodes`
// without caring about how the positions were ordered this boot.
const auto &p = framesetInfo.positions;
const char *name = "unknown";
if (targetIdx == p.home)
name = "home";
else if (targetIdx == p.deviceFocused)
name = "deviceFocused";
else if (targetIdx == p.textMessage)
name = "textMessage";
else if (targetIdx == p.nodelist_nodes)
name = "nodelist_nodes";
else if (targetIdx == p.nodelist_location)
name = "nodelist_location";
else if (targetIdx == p.nodelist_lastheard)
name = "nodelist_lastheard";
else if (targetIdx == p.nodelist_hopsignal)
name = "nodelist_hopsignal";
else if (targetIdx == p.nodelist_distance)
name = "nodelist_distance";
else if (targetIdx == p.nodelist_bearings)
name = "nodelist_bearings";
else if (targetIdx == p.system)
name = "system";
else if (targetIdx == p.gps)
name = "gps";
else if (targetIdx == p.lora)
name = "lora";
else if (targetIdx == p.clock)
name = "clock";
else if (targetIdx == p.chirpy)
name = "chirpy";
else if (targetIdx == p.fault)
name = "fault";
else if (targetIdx == p.waypoint)
name = "waypoint";
else if (targetIdx == p.focusedModule)
name = "focusedModule";
else if (targetIdx == p.log)
name = "log";
else if (targetIdx == p.settings)
name = "settings";
else if (targetIdx == p.wifi)
name = "wifi";
else if (p.firstFavorite != 255 && p.lastFavorite != 255 && targetIdx >= p.firstFavorite && targetIdx <= p.lastFavorite)
name = "favorite";
LOG_INFO("Screen: frame %u/%u name=%s reason=%s", (unsigned)targetIdx, (unsigned)framesetInfo.frameCount, name, reason);
}
#endif
void Screen::showFrame(FrameDirection direction)
{
// Only advance frames when UI is stable
if (ui->getUiState()->frameState == FIXED) {
#ifdef USERPREFS_UI_TEST_LOG
// Log the *intended* target before the (async) transition fires, so
// tests see a deterministic record of what was requested.
if (framesetInfo.frameCount > 0) {
uint8_t curr = ui->getUiState()->currentFrame;
uint8_t target = (direction == FrameDirection::NEXT)
? (uint8_t)((curr + 1) % framesetInfo.frameCount)
: (uint8_t)((curr + framesetInfo.frameCount - 1) % framesetInfo.frameCount);
logFrameChange(direction == FrameDirection::NEXT ? "next" : "prev", target);
}
#endif
if (direction == FrameDirection::NEXT) {
ui->nextFrame();
} else {
@@ -1535,6 +1608,9 @@ void Screen::setFastFramerate()
{
#if defined(M5STACK_UNITC6L)
dispdev->clear();
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
#endif
// We are about to start a transition so speed up fps
@@ -1746,7 +1822,7 @@ int Screen::handleInputEvent(const InputEvent *event)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
setFastFramerate(); // Draw ASAP
ui->update();
updateUiFrame(ui);
return 0;
}
@@ -1761,7 +1837,7 @@ int Screen::handleInputEvent(const InputEvent *event)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
setFastFramerate(); // Draw ASAP
ui->update();
updateUiFrame(ui);
menuHandler::handleMenuSwitch(dispdev);
return 0;
@@ -1847,22 +1923,37 @@ int Screen::handleInputEvent(const InputEvent *event)
showFrame(FrameDirection::NEXT);
} else if (event->inputEvent == INPUT_BROKER_FN_F1) {
this->ui->switchToFrame(0);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f1", 0);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F2) {
this->ui->switchToFrame(1);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f2", 1);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F3) {
this->ui->switchToFrame(2);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f3", 2);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F4) {
this->ui->switchToFrame(3);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f4", 3);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F5) {
this->ui->switchToFrame(4);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f5", 4);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_UP_LONG) {
+10
View File
@@ -669,6 +669,16 @@ class Screen : public concurrency::OSThread
void handleOnPress();
void handleStartFirmwareUpdateScreen();
#ifdef USERPREFS_UI_TEST_LOG
// Test-only: emits one LOG_INFO line on every frame transition so the
// pytest harness can assert which frame is shown. Gated behind a macro
// so the chatty log doesn't ship in release builds. Enabled via
// build_testing_profile(enable_ui_log=True) in mcp-server/userprefs.py.
// Member function (not free) because FramesetInfo is a private nested
// type — only methods of Screen can reach it.
void logFrameChange(const char *reason, uint8_t targetIdx);
#endif
// Info collected by setFrames method.
// Index location of specific frames.
// - Used to apply the FrameFocus parameter of setFrames
+260 -60
View File
@@ -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
@@ -65,6 +69,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 *
// *********************************
@@ -85,7 +95,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;
@@ -100,30 +111,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);
@@ -152,6 +226,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;
@@ -180,6 +265,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 {
@@ -194,21 +288,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
}
}
@@ -253,6 +367,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;
@@ -278,7 +395,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);
@@ -293,7 +410,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);
@@ -309,7 +426,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);
@@ -323,7 +440,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);
@@ -351,7 +468,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
@@ -393,6 +512,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
}
@@ -430,14 +559,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;
@@ -451,65 +589,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;
}
+3 -1
View File
@@ -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);
+819
View File
@@ -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 &region = 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
+163
View File
@@ -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
+142 -43
View File
@@ -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
@@ -1140,7 +1134,9 @@ static LGFX *tft = nullptr;
#endif
#include "SPILock.h"
#include "TFTColorRegions.h"
#include "TFTDisplay.h"
#include "TFTPalette.h"
#include <SPI.h>
#ifdef UNPHONE
@@ -1150,6 +1146,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!");
@@ -1189,14 +1204,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;
@@ -1205,12 +1221,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) {
@@ -1219,7 +1293,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;
@@ -1237,13 +1311,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;
}
@@ -1253,43 +1328,51 @@ void TFTDisplay::display(bool fromBlank)
}
// Did we find a pixel that needs updating on this row?
if (x_FirstPixelUpdate < displayWidth) {
// 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 &= ~1;
// Step 3a: copy rest of the pixels in this row into the pixel line buffer,
// while also recording the last pixel in the row that needs updating.
// Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set.
for (x = x_FirstPixelUpdate; 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--;
}
// Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs.
// Most displays will have even number of pixels in a row -- this will be in bounds
// of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.)
x_LastPixelUpdate |= 1;
// Ensure the last pixel index does not exceed the display width.
// 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],
(x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1);
#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;
}
@@ -1298,6 +1381,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()
@@ -1511,7 +1602,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);
@@ -1521,6 +1612,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;
}
+2 -1
View File
@@ -63,4 +63,5 @@ class TFTDisplay : public OLEDDisplay
virtual bool connect() override;
uint16_t *linePixelBuffer = nullptr;
};
uint16_t *repaintChunkBuffer = nullptr;
};
+70
View File
@@ -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
+7 -3
View File
@@ -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
+34 -86
View File
@@ -9,113 +9,61 @@ 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 = cosf(angle);
float sin_a = sinf(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;
}
float northX = 0.0f;
float northY = -radius;
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) {
const float c = cosf(-myHeading);
const float s = sinf(-myHeading);
const float rx = northX * c - northY * s;
const float ry = northX * s + northY * c;
northX = rx;
northY = ry;
}
northX += compassX;
northY += 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(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
display->fillRect(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
} else {
display->fillRect(northX - 4, northY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
}
display->setColor(WHITE);
display->drawString(northX, northY - 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);
display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY);
display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY);
}
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);
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
{
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)
+1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "graphics/Screen.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
+30 -5
View File
@@ -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"
@@ -469,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;
@@ -479,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;
}
@@ -492,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;
@@ -511,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++],
@@ -584,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
+63 -122
View File
@@ -11,6 +11,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"
@@ -30,8 +31,6 @@
#include <functional>
#include <utility>
extern uint16_t TFT_MESH;
namespace graphics
{
@@ -2028,109 +2027,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"};
@@ -2318,9 +2214,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
@@ -2329,13 +2225,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;
@@ -2345,6 +2234,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;
@@ -2354,9 +2248,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();
@@ -2366,6 +2257,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();
@@ -2649,6 +2543,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)
@@ -2724,9 +2665,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MuiPicker:
switchToMUIMenu();
break;
case TftColorMenuPicker:
TFTColorPickerMenu(display);
break;
case BrightnessPicker:
BrightnessPickerMenu();
break;
@@ -2799,6 +2737,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MessageBubblesMenu:
messageBubblesMenu();
break;
case ThemeMenu:
themeMenu();
break;
}
menuQueue = MenuNone;
}
@@ -2810,4 +2751,4 @@ void menuHandler::saveUIConfig()
} // namespace graphics
#endif
#endif
+3 -16
View File
@@ -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>;
+132 -12
View File
@@ -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) {
@@ -648,6 +720,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 +763,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 +823,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 +892,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;
+85 -18
View File
@@ -11,6 +11,8 @@
#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>
@@ -213,6 +215,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 || !node->is_favorite || !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
// =============================
@@ -227,6 +256,9 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char timeStr[10];
@@ -275,14 +307,20 @@ 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);
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -304,28 +342,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);
}
}
@@ -340,6 +398,9 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char distStr[10] = "";
@@ -445,6 +506,9 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -700,6 +764,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);
+35 -1
View File
@@ -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"
@@ -608,6 +610,9 @@ 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;
@@ -630,7 +635,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) {
@@ -650,6 +669,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 +684,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);
File diff suppressed because it is too large Load Diff
+2
View File
@@ -54,6 +54,8 @@ class UIRenderer
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);
@@ -0,0 +1,255 @@
/*
NicheGraphics parallel E-Ink driver for the LilyGo T5-S3-ePaper-Pro (ED047TC1).
InkHUD buffer format : 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
Both formats share the same pixel layout and polarity (1 = white, 0 = black).
The InkHUD safe-area buffer (944×523) is copied into the centre of the physical
960×540 FastEPD buffer so content clears the panel's inactive edge border.
See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants.
*/
// Ruler diagnostic — uncomment to draw calibration lines at each physical edge.
// #define EINK_EDGE_LINES
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#ifdef T5_S3_EPAPER_PRO
#include "./ED047TC1.h"
#include "FastEPD.h"
#include "configuration.h"
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
(void)spi;
(void)pin_dc;
(void)pin_cs;
(void)pin_busy;
(void)pin_rst;
SafeFastEPD *safeEpaper = new SafeFastEPD;
epaper = safeEpaper;
int initRc = BBEP_ERROR_BAD_PARAMETER;
#if defined(T5_S3_EPAPER_PRO_V1)
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
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
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)
{
if (!epaper)
return;
// InkHUD renders into a DISPLAY_WIDTH × DISPLAY_HEIGHT safe-area buffer.
// We need to place that into the centre of the physical 960×540 FastEPD buffer,
// leaving blank margins at every edge to avoid the panel's inactive border.
const uint32_t srcRowBytes = (DISPLAY_WIDTH + 7) / 8; // bytes per row in InkHUD buffer (118)
const uint32_t dstRowBytes = (960 + 7) / 8; // bytes per row in physical buffer (120)
const uint32_t dstTotalRows = 540;
uint8_t *cur = epaper->currentBuffer();
// Fill physical buffer with white (0xFF = white in FastEPD 1bpp)
memset(cur, 0xFF, dstRowBytes * dstTotalRows);
// Copy each InkHUD row into the physical buffer with horizontal + vertical offsets
for (uint32_t row = 0; row < DISPLAY_HEIGHT; row++) {
const uint8_t *srcRow = imageData + row * srcRowBytes;
uint8_t *dstRow = cur + (row + V_OFFSET_TOP) * dstRowBytes + H_OFFSET_BYTES;
memcpy(dstRow, srcRow, srcRowBytes);
}
#ifdef EINK_EDGE_LINES
// Draw a 1px black box at the exact boundary of the safe area within the
// physical buffer. If the margins are correct, all 4 lines should be
// fully visible and right at the edge of the usable display area.
auto setPixelBlack = [&](uint32_t col, uint32_t row) { cur[row * dstRowBytes + col / 8] &= ~(0x80 >> (col % 8)); };
const uint32_t safeX = H_OFFSET_BYTES * 8;
const uint32_t safeY = V_OFFSET_TOP;
const uint32_t safeW = DISPLAY_WIDTH;
const uint32_t safeH = DISPLAY_HEIGHT;
// Top edge: horizontal line at safeY
for (uint32_t col = safeX; col < safeX + safeW; col++)
setPixelBlack(col, safeY);
// Bottom edge: horizontal line at safeY + safeH - 1
for (uint32_t col = safeX; col < safeX + safeW; col++)
setPixelBlack(col, safeY + safeH - 1);
// Left edge: vertical line at safeX
for (uint32_t row = safeY; row < safeY + safeH; row++)
setPixelBlack(safeX, row);
// Right edge: vertical line at safeX + safeW - 1
for (uint32_t row = safeY; row < safeY + safeH; row++)
setPixelBlack(safeX + safeW - 1, row);
#endif
if (type == FULL) {
epaper->fullUpdate(CLEAR_SLOW, false);
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
} else {
// 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);
}
}
#endif // T5_S3_EPAPER_PRO
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
@@ -0,0 +1,90 @@
/*
E-Ink display driver adapter
- ED047TC1 (via FastEPD library)
- Manufacturer: E Ink / used in LilyGo T5-E-Paper-S3-Pro
- Size: 4.7 inch
- Physical resolution: 960px x 540px
- Interface: 8-bit parallel (NOT SPI)
Unlike the other NicheGraphics EInk drivers, this one drives a parallel e-paper
panel via the FastEPD library. SPI parameters passed to begin() are ignored.
The ED047TC1 panel has an inactive pixel border on all four edges (~4–8 physical
pixels). DISPLAY_WIDTH / DISPLAY_HEIGHT expose a reduced "safe area" to InkHUD so
that content is never drawn into this dead zone. The update() method copies the
InkHUD frame buffer into the centre of the larger physical 960×540 buffer, using
H_OFFSET_BYTES (horizontal, whole bytes = 8 pixels per byte),
V_OFFSET_TOP and V_OFFSET_BOTTOM (vertical, pixel rows) to position it.
Changing these constants shifts content inward from each physical edge:
H_OFFSET_BYTES = 1 → 8px left margin, 8px right margin (960 – 8 – 8 = 944)
V_OFFSET_TOP = 9 → 9px top margin (asymmetric: top ≠ bottom)
V_OFFSET_BOTTOM = 8 → 8px bottom margin (540 – 9 – 8 = 523)
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./EInk.h"
// Forward declare to avoid pulling FastEPD into all translation units
class FASTEPD;
namespace NicheGraphics::Drivers
{
class ED047TC1 : public EInk
{
// Safe-area dimensions exposed to InkHUD (physical panel is 960×540).
//
// The ED047TC1 has an inactive pixel border on all physical edges.
// The physical buffer coordinates do NOT directly match the visual orientation
// due to FastEPD's portrait scan direction and InkHUD's rotation=3 (270° CW):
//
// Physical buffer Visual on device (rotation=3)
// ───────────────── ──────────────────────────────
// Physical LEFT cols → Visual TOP edge
// Physical RIGHT cols → Visual BOTTOM edge
// Physical TOP rows → Visual RIGHT edge
// Physical BOTTOM rows → Visual LEFT edge
//
// Offset constants shift the InkHUD safe-area away from each physical dead zone:
// H_OFFSET_BYTES : whole bytes from physical left (8px per byte, affects visual TOP)
// Physical right margin = 960 − H_OFFSET_BYTES×8 − DISPLAY_WIDTH (affects visual BOTTOM)
// V_OFFSET_TOP : pixel rows from physical top (affects visual RIGHT)
// V_OFFSET_BOTTOM: pixel rows from physical bottom (affects visual LEFT)
//
// Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible.
static constexpr uint16_t DISPLAY_WIDTH = 944; // 960 − H_OFFSET_BYTES×8 − right_margin (8+8 = 16px)
static constexpr uint16_t DISPLAY_HEIGHT = 523; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (9+8 = 17px)
static constexpr uint8_t H_OFFSET_BYTES = 1; // visual TOP : 8px physical left margin
// visual BOTTOM: 960−8−944=8px physical right margin
static constexpr uint8_t V_OFFSET_TOP = 9; // visual RIGHT : CONFIRMED OK
static constexpr uint8_t V_OFFSET_BOTTOM = 8; // visual LEFT : 8px physical bottom margin
static constexpr UpdateTypes supported = static_cast<UpdateTypes>(FULL | FAST);
public:
ED047TC1() : EInk(DISPLAY_WIDTH, DISPLAY_HEIGHT, supported) {}
// EInk interface — SPI params are not used for this parallel display
void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = 0xFF) override;
void update(uint8_t *imageData, UpdateTypes type) override;
protected:
bool isUpdateDone() override { return true; } // FastEPD updates are blocking
private:
FASTEPD *epaper = nullptr;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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 |
+4
View File
@@ -88,8 +88,12 @@ class AppletFont
// Greek
#include "graphics/niche/Fonts/FreeSans12pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans18pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans24pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans6pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans9pt_Win1253.h"
#define FREESANS_24PT_WIN1253 InkHUD::AppletFont(FreeSans24pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -5, 3)
#define FREESANS_18PT_WIN1253 InkHUD::AppletFont(FreeSans18pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -4, 2)
#define FREESANS_12PT_WIN1253 InkHUD::AppletFont(FreeSans12pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -3, 1)
#define FREESANS_9PT_WIN1253 InkHUD::AppletFont(FreeSans9pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -2, -1)
#define FREESANS_6PT_WIN1253 InkHUD::AppletFont(FreeSans6pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -1, -2)
@@ -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);
@@ -143,17 +143,19 @@ void InkHUD::MapApplet::onRender(bool full)
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);
}
}
@@ -382,9 +384,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;
@@ -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
@@ -109,6 +109,7 @@ enum MenuAction {
TOGGLE_CHANNEL_POSITION,
SET_CHANNEL_PRECISION,
// Display
SET_DISPLAY_TIMEOUT,
TOGGLE_DISPLAY_UNITS,
// Network
TOGGLE_WIFI,
@@ -119,4 +120,4 @@ enum MenuAction {
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -8,6 +8,7 @@
#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 +28,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 +50,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 +129,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 +162,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 +194,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
@@ -333,17 +423,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);
@@ -422,14 +509,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;
@@ -527,6 +627,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;
@@ -893,11 +1004,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));
@@ -1109,6 +1225,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";
@@ -1118,6 +1237,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));
@@ -1386,10 +1512,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);
@@ -1422,6 +1552,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
// ===================
@@ -1449,8 +1584,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);
@@ -1459,8 +1592,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);
@@ -1468,9 +1610,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);
@@ -1499,13 +1641,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
@@ -1566,6 +1797,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
@@ -1595,6 +1852,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
@@ -1727,6 +2007,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()
@@ -1734,8 +2025,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
@@ -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
@@ -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
@@ -47,6 +47,9 @@ InkHUD::TipsApplet::TipsApplet()
void InkHUD::TipsApplet::onRender(bool full)
{
const char *continuePrompt =
(inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue";
switch (tipQueue.front()) {
case Tip::WELCOME:
renderWelcome();
@@ -79,7 +82,7 @@ void InkHUD::TipsApplet::onRender(bool full)
cursorY += fontSmall.lineHeight() / 2;
drawBullet("More info at meshtastic.org");
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::PICK_REGION: {
@@ -109,7 +112,7 @@ void InkHUD::TipsApplet::onRender(bool full)
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::CUSTOMIZATION: {
@@ -129,10 +132,13 @@ void InkHUD::TipsApplet::onRender(bool full)
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::BUTTONS: {
#if defined(T5_S3_EPAPER_PRO)
renderT5S3ButtonsTip();
#else
setFont(fontMedium);
const char *title = "Tip: Buttons";
@@ -164,7 +170,8 @@ void InkHUD::TipsApplet::onRender(bool full)
drawBullet("- press: switch tile or close menu");
}
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
#endif
} break;
case Tip::ROTATION: {
@@ -189,7 +196,7 @@ void InkHUD::TipsApplet::onRender(bool full)
"To rotate the display, use the InkHUD menu. Press the user button > Options > Rotate.");
}
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
// Revert the "flip screen" setting, preventing this message showing again
config.display.flip_screen = false;
@@ -198,6 +205,42 @@ void InkHUD::TipsApplet::onRender(bool full)
}
}
#if defined(T5_S3_EPAPER_PRO)
void InkHUD::TipsApplet::renderT5S3ButtonsTip()
{
setFont(fontMedium);
const char *title = "Tip: T5-S3 Buttons";
uint16_t h = getWrappedTextHeight(0, width(), title);
printWrapped(0, 0, width(), title);
setFont(fontSmall);
int16_t cursorY = h + fontSmall.lineHeight();
auto drawBullet = [&](const char *text) {
uint16_t bh = getWrappedTextHeight(0, width(), text);
printWrapped(0, cursorY, width(), text);
cursorY += bh + (fontSmall.lineHeight() / 3);
};
drawBullet("BOOT button");
drawBullet("- short press: next");
drawBullet("- long press: open menu or select");
drawBullet("IO48 button");
drawBullet("- short press: toggle touch on/off");
drawBullet("- long press: toggle backlight on/off");
drawBullet("PWR button");
drawBullet("- Hold Press to wake after Shutdown");
drawBullet("HOME button");
drawBullet("- press: back/exit in InkHUD and open App switcher");
printAt(0, Y(1.0), "Tap screen to continue", LEFT, BOTTOM);
}
#endif
// This tip has its own render method, only because it's a big block of code
// Didn't want to clutter up the switch in onRender too much
void InkHUD::TipsApplet::renderWelcome()
@@ -244,7 +287,9 @@ void InkHUD::TipsApplet::renderWelcome()
// Block 3 - press to continue
// ============================
printAt(X(0.5), Y(1), "Press button to continue", CENTER, BOTTOM);
const char *continuePrompt =
(inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue";
printAt(X(0.5), Y(1), continuePrompt, CENTER, BOTTOM);
}
void InkHUD::TipsApplet::onForeground()
@@ -41,6 +41,9 @@ class TipsApplet : public SystemApplet
protected:
void renderWelcome(); // Very first screen of tutorial
#if defined(T5_S3_EPAPER_PRO)
void renderT5S3ButtonsTip();
#endif
std::deque<Tip> tipQueue; // List of tips to show, one after another
@@ -49,4 +52,4 @@ class TipsApplet : public SystemApplet
} // namespace NicheGraphics::InkHUD
#endif
#endif
+272 -148
View File
@@ -2,6 +2,7 @@
#include "./Events.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "buzz.h"
#include "modules/ExternalNotificationModule.h"
@@ -14,6 +15,19 @@
using namespace NicheGraphics;
namespace
{
// When touch long-press opens menu, some panels report a delayed release/tap
// if the finger stays down briefly. Keep this long enough to cover that release.
constexpr uint32_t TOUCH_MENU_OPEN_TAP_SUPPRESS_MS = 1200;
inline void noteInkHUDUserInteraction()
{
// Keep power state and screen-timeout behavior in sync with InkHUD input activity.
powerFSM.trigger(EVENT_INPUT);
}
} // namespace
InkHUD::Events::Events()
{
// Get convenient references
@@ -38,6 +52,8 @@ void InkHUD::Events::begin()
void InkHUD::Events::onButtonShort()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
@@ -74,6 +90,8 @@ void InkHUD::Events::onButtonShort()
void InkHUD::Events::onButtonLong()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Slightly longer than playChirp
playBoop();
@@ -102,193 +120,295 @@ void InkHUD::Events::onButtonLong()
void InkHUD::Events::onExitShort()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Preserve legacy behavior on non-touch builds:
// EXIT input is only active when joystick mode is enabled.
// Touch-capable builds intentionally bypass this joystick gate.
if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) {
return;
}
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification module (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is change tiles
if (consumer)
consumer->onExitShort();
else if (!dismissedExt) { // Don't change tile if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
// Always let active system applets consume EXIT/HOME input (menu close, keyboard cancel, etc),
// including on touch-first nodes where joystick mode is disabled.
if (consumer) {
consumer->onExitShort();
return;
}
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT))
userConsumer->onExitShort();
else
inkhud->nextTile();
// Touch-capable InkHUD nodes use EXIT/HOME as a quick app switcher launcher.
if (!dismissedExt && inkhud->hasTouchEnabledProvider()) {
inkhud->openAppSwitcher();
return;
}
if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT)) {
userConsumer->onExitShort();
} else if (settings->joystick.enabled) {
// Preserve existing joystick behavior when no applet handles EXIT.
inkhud->nextTile();
}
}
}
void InkHUD::Events::onExitLong()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Slightly longer than playChirp
playBoop();
// Preserve legacy behavior on non-touch builds:
// EXIT input is only active when joystick mode is enabled.
// Touch-capable builds intentionally bypass this joystick gate.
if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) {
return;
}
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Slightly longer than playChirp
playBoop();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onExitLong();
else {
Applet *userConsumer = inkhud->getActiveApplet();
// Always allow system applets to consume EXIT/HOME long-press.
if (consumer) {
consumer->onExitLong();
} else {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG))
userConsumer->onExitLong();
// Nothing uses exit long yet
}
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG))
userConsumer->onExitLong();
// Nothing uses exit long yet
}
}
void InkHUD::Events::onNavUp()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavUp();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP))
userConsumer->onNavUp();
}
}
if (settings->joystick.enabled)
onTouchNavUp();
}
void InkHUD::Events::onNavDown()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavDown();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN))
userConsumer->onNavDown();
}
}
if (settings->joystick.enabled)
onTouchNavDown();
}
void InkHUD::Events::onNavLeft()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavLeft();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT))
userConsumer->onNavLeft();
else
inkhud->prevApplet();
}
}
if (settings->joystick.enabled)
onTouchNavLeft();
}
void InkHUD::Events::onNavRight()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
if (settings->joystick.enabled)
onTouchNavRight();
}
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
void InkHUD::Events::onTouchNavUp()
{
noteInkHUDUserInteraction();
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavRight();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
userConsumer->onNavRight();
else
inkhud->nextApplet();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavUp();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP))
userConsumer->onNavUp();
}
}
void InkHUD::Events::onTouchNavDown()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavDown();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN))
userConsumer->onNavDown();
}
}
void InkHUD::Events::onTouchNavLeft()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavLeft();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT))
userConsumer->onNavLeft();
else
inkhud->prevApplet();
}
}
void InkHUD::Events::onTouchNavRight()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavRight();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
userConsumer->onNavRight();
else
inkhud->nextApplet();
}
}
void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress)
{
const bool touchEnabledBuild = inkhud->hasTouchEnabledProvider();
// A long-press used to open the menu can be followed by a synthetic/queued tap at release.
// Ignore that brief follow-up window so touch-opened menus do not auto-select an item.
if (touchEnabledBuild && !longPress && suppressTouchTapUntilMs != 0) {
if ((int32_t)(millis() - suppressTouchTapUntilMs) < 0) {
noteInkHUDUserInteraction();
return;
}
suppressTouchTapUntilMs = 0;
}
// Give system applets (menu, keyboard, etc) first chance to consume direct touch input.
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput && sa->onTouchPoint(x, y, longPress)) {
noteInkHUDUserInteraction();
return;
}
}
// In split layouts, tapping a different tile changes focus.
// Consume this tap so selection does not also trigger button fallback behavior.
if (inkhud->selectTileAt(x, y)) {
noteInkHUDUserInteraction();
return;
}
// Allow foreground user applet to consume direct touch input if it wants.
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->onTouchPoint(x, y, longPress)) {
noteInkHUDUserInteraction();
return;
}
// Fallback to existing button semantics so non-touch-aware applets keep working unchanged.
if (longPress) {
onButtonLong();
// Only arm suppression if the long-press actually opened menu foreground.
SystemApplet *menu = inkhud->getSystemApplet("Menu");
if (touchEnabledBuild && menu && menu->isForeground()) {
suppressTouchTapUntilMs = millis() + TOUCH_MENU_OPEN_TAP_SUPPRESS_MS;
}
} else
onButtonShort();
}
void InkHUD::Events::onFreeText(char c)
{
noteInkHUDUserInteraction();
// Trigger the first system applet that wants to handle the new character
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -300,6 +420,8 @@ void InkHUD::Events::onFreeText(char c)
void InkHUD::Events::onFreeTextDone()
{
noteInkHUDUserInteraction();
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -311,6 +433,8 @@ void InkHUD::Events::onFreeTextDone()
void InkHUD::Events::onFreeTextCancel()
{
noteInkHUDUserInteraction();
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -487,4 +611,4 @@ bool InkHUD::Events::dismissExternalNotification()
return true;
}
#endif
#endif
+16 -7
View File
@@ -17,6 +17,7 @@ however this class handles general events which concern InkHUD as a whole, e.g.
#include "./InkHUD.h"
#include "./Persistence.h"
#include <stdint.h>
namespace NicheGraphics::InkHUD
{
@@ -30,12 +31,17 @@ class Events
void onButtonShort(); // User button: short press
void onButtonLong(); // User button: long press
void applyingChanges();
void onExitShort(); // Exit button: short press
void onExitLong(); // Exit button: long press
void onNavUp(); // Navigate up
void onNavDown(); // Navigate down
void onNavLeft(); // Navigate left
void onNavRight(); // Navigate right
void onExitShort(); // Exit button: short press
void onExitLong(); // Exit button: long press
void onNavUp(); // Navigate up
void onNavDown(); // Navigate down
void onNavLeft(); // Navigate left
void onNavRight(); // Navigate right
void onTouchNavUp(); // Navigate up from touch input
void onTouchNavDown(); // Navigate down from touch input
void onTouchNavLeft(); // Navigate left from touch input
void onTouchNavRight(); // Navigate right from touch input
void onTouchTap(uint16_t x, uint16_t y, bool longPress); // Touch tap/long-press with coordinates
// Free text typing events
void onFreeText(char c); // New freetext character input
@@ -79,8 +85,11 @@ class Events
// If set, InkHUD's data will be erased during onReboot
bool eraseOnReboot = false;
// Suppress follow-up tap generated immediately after a touch long-press opens menu.
uint32_t suppressTouchTapUntilMs = 0;
};
} // namespace NicheGraphics::InkHUD
#endif
#endif
+121 -1
View File
@@ -73,6 +73,24 @@ void InkHUD::InkHUD::begin()
// LogoApplet shows boot screen here
}
void InkHUD::InkHUD::setTouchEnabledProvider(TouchEnabledProvider provider)
{
touchEnabledProvider = provider;
}
bool InkHUD::InkHUD::hasTouchEnabledProvider() const
{
return touchEnabledProvider != nullptr;
}
bool InkHUD::InkHUD::isTouchEnabled() const
{
if (!touchEnabledProvider)
return true;
return touchEnabledProvider();
}
// Call this when your user button gets a short press
// Should be connected to an input source in nicheGraphics.h (NicheGraphics::Inputs::TwoButton?)
void InkHUD::InkHUD::shortpress()
@@ -175,6 +193,92 @@ void InkHUD::InkHUD::navRight()
}
}
// Call this when touch input needs joystick-like up navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavUp()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavLeft();
break;
case 2: // 180 deg
events->onTouchNavDown();
break;
case 3: // 270 deg
events->onTouchNavRight();
break;
default: // 0 deg
events->onTouchNavUp();
break;
}
}
// Call this when touch input needs joystick-like down navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavDown()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavRight();
break;
case 2: // 180 deg
events->onTouchNavUp();
break;
case 3: // 270 deg
events->onTouchNavLeft();
break;
default: // 0 deg
events->onTouchNavDown();
break;
}
}
// Call this when touch input needs joystick-like left navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavLeft()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavDown();
break;
case 2: // 180 deg
events->onTouchNavRight();
break;
case 3: // 270 deg
events->onTouchNavUp();
break;
default: // 0 deg
events->onTouchNavLeft();
break;
}
}
// Call this when touch input needs joystick-like right navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavRight()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavUp();
break;
case 2: // 180 deg
events->onTouchNavLeft();
break;
case 3: // 270 deg
events->onTouchNavDown();
break;
default: // 0 deg
events->onTouchNavRight();
break;
}
}
void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y)
{
events->onTouchTap(x, y, false);
}
void InkHUD::InkHUD::touchLongPress(uint16_t x, uint16_t y)
{
events->onTouchTap(x, y, true);
}
// Call this for keyboard input
// The Keyboard Applet also calls this
void InkHUD::InkHUD::freeText(char c)
@@ -223,6 +327,12 @@ void InkHUD::InkHUD::openMenu()
windowManager->openMenu();
}
// Show touch-friendly app switcher (on the focused tile)
void InkHUD::InkHUD::openAppSwitcher()
{
windowManager->openAppSwitcher();
}
// Bring AlignStick applet to the foreground
void InkHUD::InkHUD::openAlignStick()
{
@@ -255,6 +365,16 @@ void InkHUD::InkHUD::prevTile()
windowManager->prevTile();
}
bool InkHUD::InkHUD::showApplet(uint8_t appletIndex)
{
return windowManager->showApplet(appletIndex);
}
bool InkHUD::InkHUD::selectTileAt(uint16_t x, uint16_t y)
{
return windowManager->selectTileAt(x, y);
}
// Rotate the display image by 90 degrees
void InkHUD::InkHUD::rotate()
{
@@ -376,4 +496,4 @@ void InkHUD::InkHUD::drawPixel(int16_t x, int16_t y, Color c)
renderer->handlePixel(x, y, c);
}
#endif
#endif

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