mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 03:18:10 +10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bdb5830b | ||
|
|
f9f450977b | ||
|
|
f74d3e4816 | ||
|
|
55954c20b7 | ||
|
|
55a580e19e |
@@ -75,11 +75,11 @@ body:
|
||||
- type: checkboxes
|
||||
id: mui
|
||||
attributes:
|
||||
label: Is this bug report about any UI (https://meshtastic.org/docs/configuration/device-uis/) component firmware?
|
||||
label: Is this bug report about any UI component firmware like InkHUD or Meshtatic UI (MUI)?
|
||||
options:
|
||||
- label: Meshtastic UI aka MUI
|
||||
- label: InkHUD
|
||||
- label: BaseUI
|
||||
- label: Meshtastic UI aka MUI colorTFT
|
||||
- label: InkHUD ePaper
|
||||
- label: OLED slide UI on any display
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
|
||||
@@ -13,7 +13,6 @@ Meshtastic is an open-source LoRa mesh networking project for long-range, low-po
|
||||
- **RP2040/RP2350** - Raspberry Pi Pico variants
|
||||
- **STM32WL** - STM32 with integrated LoRa
|
||||
- **Linux/Portduino** - Native Linux builds (Raspberry Pi, etc.)
|
||||
- **macOS native** - Headless `meshtasticd` on Apple Silicon / x86_64; see `variants/native/portduino/platformio.ini` for Homebrew prereqs + CH341 LoRa setup
|
||||
|
||||
### Supported Radio Chips
|
||||
|
||||
@@ -71,70 +70,6 @@ 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
|
||||
|
||||
```
|
||||
@@ -145,7 +80,7 @@ firmware/
|
||||
│ │ ├── NodeDB.* # Node database management
|
||||
│ │ ├── Router.* # Packet routing
|
||||
│ │ ├── Channels.* # Channel management
|
||||
│ │ ├── CryptoEngine.* # AES-CTR (channels) + X25519 ECDH→AES-256-CCM (PKI for DMs/admin)
|
||||
│ │ ├── CryptoEngine.* # AES-CCM encryption
|
||||
│ │ ├── *Interface.* # Radio interface implementations
|
||||
│ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI)
|
||||
│ │ ├── http/ # HTTP server (WebServer, ContentHandler)
|
||||
@@ -361,23 +296,6 @@ 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` (fallback for non-Linux hosts; macOS can also build natively via `pio run -e native-macos`)
|
||||
|
||||
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
|
||||
@@ -389,7 +307,6 @@ Build commands:
|
||||
pio run -e tbeam # Build specific target
|
||||
pio run -e tbeam -t upload # Build and upload
|
||||
pio run -e native # Build native/Linux version
|
||||
pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prereqs in variants/native/portduino/platformio.ini)
|
||||
```
|
||||
|
||||
### Build Manifest
|
||||
@@ -531,8 +448,6 @@ 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.
|
||||
@@ -575,8 +490,6 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v
|
||||
|
||||
`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`.
|
||||
|
||||
**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes".
|
||||
|
||||
### Hardware test suite (`mcp-server/run-tests.sh`)
|
||||
|
||||
The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_<ROLE>`), then invokes pytest. Zero pre-flight config needed from the operator.
|
||||
|
||||
@@ -118,7 +118,7 @@ CallbackObserver<MyModule, const meshtastic::Status *> statusObserver =
|
||||
|
||||
Add test suite in `test/test_mymodule/`:
|
||||
|
||||
```text
|
||||
```
|
||||
test/
|
||||
└── test_mymodule/
|
||||
└── test_main.cpp
|
||||
|
||||
@@ -6,7 +6,7 @@ Guide for adding a new Meshtastic hardware variant to the firmware.
|
||||
|
||||
Create under `variants/<arch>/<name>/`:
|
||||
|
||||
```text
|
||||
```
|
||||
variants/
|
||||
├── esp32/ # ESP32
|
||||
├── esp32s3/ # ESP32-S3
|
||||
|
||||
@@ -32,15 +32,10 @@ jobs:
|
||||
shell: bash
|
||||
working-directory: meshtasticd
|
||||
run: |
|
||||
# Build-tools (notably platformio) come from the Meshtastic project
|
||||
# on the OpenSUSE Build Service:
|
||||
# https://build.opensuse.org/project/show/network:Meshtastic:build-tools
|
||||
echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/build-tools/xUbuntu_24.04/ /' \
|
||||
| sudo tee /etc/apt/sources.list.d/network:Meshtastic:build-tools.list
|
||||
curl -fsSL https://download.opensuse.org/repositories/network:Meshtastic:build-tools/xUbuntu_24.04/Release.key \
|
||||
| gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/network_Meshtastic_build-tools.gpg >/dev/null
|
||||
sudo apt-get update -y --fix-missing
|
||||
sudo apt-get install -y build-essential devscripts equivs
|
||||
sudo apt-get install -y software-properties-common build-essential devscripts equivs
|
||||
sudo add-apt-repository ppa:meshtastic/build-tools -y
|
||||
sudo apt-get update -y --fix-missing
|
||||
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
|
||||
|
||||
- name: Import GPG key
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Build MacOS Binary
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
macos_ver:
|
||||
required: false
|
||||
default: "26" # ARM64
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-MacOS:
|
||||
runs-on: macos-${{ inputs.macos_ver }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install deps
|
||||
shell: bash
|
||||
run: |
|
||||
brew update
|
||||
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius
|
||||
|
||||
- name: Get release version string
|
||||
run: |
|
||||
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
id: version
|
||||
|
||||
- name: Build for MacOS
|
||||
run: |
|
||||
platformio run -e native-macos
|
||||
env:
|
||||
PKG_VERSION: ${{ steps.version.outputs.long }}
|
||||
# Errors in this step should not fail the entire workflow while MacOS support is in development.
|
||||
continue-on-error: true
|
||||
|
||||
- name: List output files
|
||||
run: ls -lah .pio/build/native-macos/
|
||||
|
||||
- name: Store binaries as an artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: firmware-macos-${{ inputs.macos_ver }}-${{ steps.version.outputs.long }}
|
||||
overwrite: true
|
||||
path: |
|
||||
.pio/build/native-macos/meshtasticd
|
||||
@@ -4,14 +4,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# trunk-ignore(checkov/CKV_GHA_7)
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
arch:
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
@@ -20,18 +15,32 @@ on:
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
description: Choose an arch to limit the search, or 'all' to search all architectures.
|
||||
default: all
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
# find-target:
|
||||
# type: boolean
|
||||
# default: true
|
||||
# description: 'Find the available targets'
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
find-targets:
|
||||
if: ${{ inputs.target == '' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
- esp32c6
|
||||
- nrf52840
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -42,37 +51,14 @@ jobs:
|
||||
- run: pip install -U platformio
|
||||
- name: Generate matrix
|
||||
id: jsonStep
|
||||
env:
|
||||
BUILDTARGET: ${{ inputs.target }}
|
||||
MATRIXARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
|
||||
if [ "$BUILDTARGET" = "" ]; then
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "We build this one:" >> $GITHUB_STEP_SUMMARY
|
||||
ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform')
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "$ARCH" == "" ]]; then
|
||||
echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY
|
||||
echo "arch=$ARCH" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
outputs:
|
||||
arch: ${{ steps.jsonStep.outputs.arch }}
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Targets:" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
version:
|
||||
if: ${{ inputs.target != '' }}
|
||||
@@ -92,12 +78,12 @@ jobs:
|
||||
|
||||
build:
|
||||
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
||||
needs: [version, find-targets]
|
||||
needs: [version]
|
||||
uses: ./.github/workflows/build_firmware.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.long }}
|
||||
pio_env: ${{ inputs.target }}
|
||||
platform: ${{ needs.find-targets.outputs.arch }}
|
||||
platform: ${{ inputs.arch }}
|
||||
|
||||
gather-artifacts:
|
||||
permissions:
|
||||
|
||||
@@ -73,9 +73,7 @@ jobs:
|
||||
- name: Sanitize platform string
|
||||
id: sanitize_platform
|
||||
# Replace slashes with underscores
|
||||
env:
|
||||
plat: ${{ inputs.platform }}
|
||||
run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
|
||||
run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker login
|
||||
if: ${{ inputs.push }}
|
||||
|
||||
@@ -43,15 +43,6 @@ jobs:
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-debian-riscv64:
|
||||
uses: ./.github/workflows/docker_build.yml
|
||||
with:
|
||||
distro: debian
|
||||
platform: linux/riscv64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-alpine-amd64:
|
||||
uses: ./.github/workflows/docker_build.yml
|
||||
with:
|
||||
@@ -79,27 +70,16 @@ jobs:
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-alpine-riscv64:
|
||||
uses: ./.github/workflows/docker_build.yml
|
||||
with:
|
||||
distro: alpine
|
||||
platform: linux/riscv64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
docker-manifest:
|
||||
needs:
|
||||
# Debian
|
||||
- docker-debian-amd64
|
||||
- docker-debian-arm64
|
||||
- docker-debian-armv7
|
||||
- docker-debian-riscv64
|
||||
# Alpine
|
||||
- docker-alpine-amd64
|
||||
- docker-alpine-arm64
|
||||
- docker-alpine-armv7
|
||||
- docker-alpine-riscv64
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -182,7 +162,6 @@ jobs:
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-amd64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-arm64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-armv7.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-debian-riscv64.outputs.digest }}
|
||||
|
||||
- name: Docker meta (Alpine)
|
||||
id: meta_alpine
|
||||
@@ -203,4 +182,3 @@ jobs:
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-amd64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-arm64.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-armv7.outputs.digest }}
|
||||
meshtastic/meshtasticd@${{ needs.docker-alpine-riscv64.outputs.digest }}
|
||||
|
||||
@@ -116,20 +116,6 @@ jobs:
|
||||
build_location: local
|
||||
secrets: inherit
|
||||
|
||||
MacOS:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
macos_ver:
|
||||
- "26" # ARM64
|
||||
# - '26-intel' # x86_64
|
||||
- "15" # ARM64
|
||||
# - '15-intel' # x86_64
|
||||
uses: ./.github/workflows/build_macos_bin.yml
|
||||
with:
|
||||
macos_ver: ${{ matrix.macos_ver }}
|
||||
# secrets: inherit
|
||||
|
||||
package-pio-deps-native-tft:
|
||||
if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/package_pio_deps.yml
|
||||
@@ -300,7 +286,6 @@ jobs:
|
||||
- gather-artifacts
|
||||
- build-debian-src
|
||||
- package-pio-deps-native-tft
|
||||
# - MacOS
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -86,13 +86,7 @@ jobs:
|
||||
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
||||
|
||||
- name: PlatformIO Tests
|
||||
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$"
|
||||
run: platformio test -e coverage -v --junit-output-path testreport.xml
|
||||
|
||||
- name: Save test results
|
||||
if: always() # run this step even if previous step failed
|
||||
|
||||
+9
-9
@@ -4,22 +4,22 @@ cli:
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.8.0
|
||||
ref: v1.7.6
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
enabled:
|
||||
- checkov@3.2.526
|
||||
- renovate@43.150.0
|
||||
- prettier@3.8.3
|
||||
- trufflehog@3.95.2
|
||||
- checkov@3.2.517
|
||||
- renovate@43.110.9
|
||||
- prettier@3.8.1
|
||||
- trufflehog@3.94.3
|
||||
- yamllint@1.38.0
|
||||
- bandit@1.9.4
|
||||
- trivy@0.70.0
|
||||
- trivy@0.69.3
|
||||
- taplo@0.10.0
|
||||
- ruff@0.15.12
|
||||
- ruff@0.15.9
|
||||
- isort@8.0.1
|
||||
- markdownlint@0.48.0
|
||||
- oxipng@10.1.1
|
||||
- oxipng@10.1.0
|
||||
- svgo@4.0.1
|
||||
- actionlint@1.7.12
|
||||
- flake8@7.3.0
|
||||
@@ -36,7 +36,7 @@ lint:
|
||||
- bin/**
|
||||
runtimes:
|
||||
enabled:
|
||||
- python@3.14.4
|
||||
- python@3.10.8
|
||||
- go@1.21.0
|
||||
- node@22.16.0
|
||||
actions:
|
||||
|
||||
@@ -10,18 +10,17 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
|
||||
|
||||
## Quick command reference
|
||||
|
||||
| Action | Command |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `pio test -e native` |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
| Action | Command |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `pio test -e native` |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
|
||||
## MCP server (device + test automation)
|
||||
|
||||
@@ -49,15 +48,6 @@ 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.
|
||||
@@ -122,21 +112,19 @@ Sequence these; don't parallelize on the same port.
|
||||
- **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.
|
||||
- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b <bundleID>`. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit.
|
||||
|
||||
## 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_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. |
|
||||
| `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`. |
|
||||
| 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`. |
|
||||
|
||||
+1
-3
@@ -3,17 +3,15 @@
|
||||
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
FROM debian:trixie AS builder
|
||||
FROM python:3.14-slim-trixie AS builder
|
||||
ARG PIO_ENV=native
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
# Install Dependencies
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
curl wget g++ zip git ca-certificates pkg-config \
|
||||
python3-pip python3-grpc-tools \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
|
||||
+3
-10
@@ -3,19 +3,12 @@
|
||||
# trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
# Ensure the Alpine version is updated in both stages of the container!
|
||||
FROM alpine:3.23 AS builder
|
||||
FROM python:3.14-alpine3.22 AS builder
|
||||
ARG PIO_ENV=native
|
||||
|
||||
# Enable Alpine community repository (for 'py3-grpcio-tools')
|
||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v$(cut -d. -f1,2 /etc/alpine-release)/community" >> /etc/apk/repositories
|
||||
|
||||
# Install Dependencies
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
|
||||
RUN apk --no-cache add \
|
||||
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
||||
py3-pip py3-grpcio-tools \
|
||||
libgpiod-dev yaml-cpp-dev bluez-dev \
|
||||
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
||||
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
||||
@@ -67,4 +60,4 @@ EXPOSE 4403
|
||||
|
||||
CMD [ "sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd" ]
|
||||
|
||||
HEALTHCHECK NONE
|
||||
HEALTHCHECK NONE
|
||||
+1
-2
@@ -31,6 +31,5 @@ basename=meshtasticd-$1-$VERSION
|
||||
pio pkg install --environment "$PIO_ENV" || platformioFailed
|
||||
pio run --environment "$PIO_ENV" || platformioFailed
|
||||
|
||||
os_name=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)"
|
||||
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)"
|
||||
cp bin/native-install.* $OUTDIR/
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, HIGH, LOW]
|
||||
MODE_TX: [HIGH, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
MODE_TX_HF: [LOW, LOW, LOW]
|
||||
MODE_GNSS: [LOW, LOW, HIGH]
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins:
|
||||
- DIO5
|
||||
- DIO6
|
||||
MODE_STBY:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_RX:
|
||||
- HIGH
|
||||
- LOW
|
||||
MODE_TX:
|
||||
- HIGH
|
||||
- HIGH
|
||||
MODE_TX_HP:
|
||||
- LOW
|
||||
- HIGH
|
||||
MODE_TX_HF:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_GNSS:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_WIFI:
|
||||
- LOW
|
||||
- LOW
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, LOW, LOW]
|
||||
MODE_TX: [LOW, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
# MODE_TX_HF: []
|
||||
# MODE_GNSS: []
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,31 +0,0 @@
|
||||
# For use with Armbian luckfox-pico-max
|
||||
# Waveshare LoRa HAT for Raspberry Pi Pico
|
||||
# https://www.waveshare.com/wiki/Pico-LoRa-SX1262
|
||||
|
||||
Meta:
|
||||
name: luckfox-pico-max-ws-raspberry-pi-pico-hat
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-pico-max # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
spidev: spidev0.0
|
||||
Busy: # GPIO1_C7 / GP2
|
||||
pin: 55
|
||||
gpiochip: 1
|
||||
line: 23
|
||||
CS: # GPIO1_C6 / GP3
|
||||
pin: 54
|
||||
gpiochip: 1
|
||||
line: 22
|
||||
Reset: # GPIO1_D1 / GP15
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
IRQ: # GPIO2_A2 / GP20
|
||||
pin: 66
|
||||
gpiochip: 2
|
||||
line: 2
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "heltec_v4_r8"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "heltec"
|
||||
}
|
||||
Vendored
-1
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/bash
|
||||
set -e
|
||||
export DEBEMAIL="jbennett@incomsystems.biz"
|
||||
export PLATFORMIO_LIBDEPS_DIR=pio/libdeps
|
||||
export PLATFORMIO_PACKAGES_DIR=pio/packages
|
||||
|
||||
+9
-67
@@ -166,73 +166,15 @@ rather than auto-`sudo`'ing mid-run.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
||||
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
||||
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
||||
| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
|
||||
|
||||
## TCP / native-host nodes
|
||||
|
||||
The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd`
|
||||
binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino).
|
||||
The daemon exposes the meshtastic TCP API on port `4403` rather than a USB
|
||||
serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`:
|
||||
|
||||
```bash
|
||||
# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini
|
||||
# for full Homebrew prereqs and CH341 LoRa-adapter setup).
|
||||
pio run -e native-macos
|
||||
~/.meshtasticd/meshtasticd
|
||||
|
||||
# 2. Point the MCP server at it.
|
||||
export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403
|
||||
```
|
||||
|
||||
**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the
|
||||
USB adapter's serial-number / product strings. Many cheap CH341 dongles
|
||||
(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0`
|
||||
and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address
|
||||
not allowed!`. Set the MAC explicitly in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# Under General:
|
||||
MACAddress: 02:CA:FE:BA:BE:01
|
||||
```
|
||||
|
||||
Use a locally-administered address (first byte's second-LSB set, e.g.
|
||||
`02:*` / `06:*` / `0A:*` / `0E:*`) to avoid colliding with a real OUI.
|
||||
|
||||
There is also a `--hwid AA:BB:CC:DD:EE:FF` CLI flag visible in
|
||||
`meshtasticd --help`, but it is **currently broken** in
|
||||
`MAC_from_string()` (`src/platform/portduino/PortduinoGlue.cpp`): the
|
||||
function strips colons from its parameter but then reads bytes from the
|
||||
global `portduino_config.mac_address`, so `--hwid` is silently overridden
|
||||
when `MACAddress:` is also set, and crashes the daemon (uncaught
|
||||
`std::invalid_argument: stoi: no conversion`) when it isn't. Use the YAML
|
||||
form until that's fixed upstream.
|
||||
|
||||
`list_devices` will surface the daemon as `tcp://localhost:4403` with
|
||||
`likely_meshtastic=True`, so `device_info`, `list_nodes`, `get_config`,
|
||||
`set_config`, `set_owner`, `send_text`, `userprefs_*`, and the admin RPCs
|
||||
auto-select it when no `port` is passed. Pass `port="tcp://other-host:9999"`
|
||||
explicitly to target a different daemon.
|
||||
|
||||
**Tools that don't apply to a TCP/native node** (no USB hardware to operate
|
||||
on) raise a clear `ConnectionError` rather than failing mysteriously:
|
||||
`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`,
|
||||
`serial_open` (use info/admin tools directly), and the vendor escape hatches
|
||||
`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env
|
||||
similarly raises — there's no upload step; use `build` and run the binary
|
||||
directly.
|
||||
|
||||
The pytest harness in `tests/` still assumes USB-attached devices per role —
|
||||
TCP-aware fixtures are not part of this surface yet.
|
||||
| Var | Default | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
||||
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
||||
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
||||
|
||||
## Hardware Test Suite
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Context manager for meshtastic interface connections (serial + TCP).
|
||||
"""Context manager for meshtastic.SerialInterface connections.
|
||||
|
||||
Every info/admin tool goes through `connect(port)` so we have a single place
|
||||
that:
|
||||
@@ -6,16 +6,8 @@ that:
|
||||
- fails fast if a serial_session is already holding the port,
|
||||
- guarantees `.close()` is called, even on exception.
|
||||
|
||||
Two transports:
|
||||
- Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`.
|
||||
- TCP: a `meshtasticd` daemon (e.g. the native macOS / Linux Portduino
|
||||
headless build) addressed as `tcp://host[:port]` (default port 4403).
|
||||
Surfaced by `devices.list_devices()` when `MESHTASTIC_MCP_TCP_HOST` is
|
||||
set, so `resolve_port(None)` auto-selects it like a USB candidate.
|
||||
|
||||
Both `SerialInterface` and `TCPInterface` block on construction waiting for
|
||||
the node database; that's fine for v1 since every tool is a short-lived
|
||||
request.
|
||||
The `SerialInterface` blocks on construction waiting for the node database;
|
||||
that's fine for v1 since every tool is a short-lived request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,107 +17,20 @@ from typing import Iterator
|
||||
|
||||
from . import devices, registry
|
||||
|
||||
DEFAULT_TCP_PORT = 4403
|
||||
TCP_SCHEME = "tcp://"
|
||||
TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST"
|
||||
|
||||
|
||||
class ConnectionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def is_tcp_port(port: str | None) -> bool:
|
||||
return bool(port) and port.startswith(TCP_SCHEME)
|
||||
|
||||
|
||||
def parse_tcp_port(port: str) -> tuple[str, int]:
|
||||
"""Parse `tcp://host[:port]` → (host, port). Defaults to 4403.
|
||||
|
||||
Validates host shape (non-empty, no path separators) and port range
|
||||
(1..65535). Raises `ConnectionError` on malformed input — never lets
|
||||
a raw `ValueError` bubble up to a tool surface.
|
||||
"""
|
||||
if not port.startswith(TCP_SCHEME):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: expected '{TCP_SCHEME}host[:port]'."
|
||||
)
|
||||
rest = port[len(TCP_SCHEME) :]
|
||||
if ":" in rest:
|
||||
host, port_str = rest.rsplit(":", 1)
|
||||
try:
|
||||
tcp_port = int(port_str)
|
||||
except ValueError as e:
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: port {port_str!r} is not an integer."
|
||||
) from e
|
||||
else:
|
||||
host, tcp_port = rest, DEFAULT_TCP_PORT
|
||||
if not host:
|
||||
raise ConnectionError(f"Invalid TCP endpoint {port!r}: empty host.")
|
||||
if any(c in host for c in ("/", "\\")):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: host {host!r} contains a path "
|
||||
"separator. TCP hostnames cannot contain '/' or '\\' — did you "
|
||||
"pass a serial port path or a Windows drive path by mistake?"
|
||||
)
|
||||
if not (1 <= tcp_port <= 65535):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: port {tcp_port} out of range "
|
||||
"(must be 1..65535)."
|
||||
)
|
||||
return host, tcp_port
|
||||
|
||||
|
||||
def normalize_tcp_endpoint(endpoint: str) -> str:
|
||||
r"""Normalize `host`, `host:port`, or `tcp://host[:port]` → canonical
|
||||
`tcp://host:port` form. One place that owns the lock-key shape.
|
||||
|
||||
Defers all validation to `parse_tcp_port`, so path-like inputs
|
||||
(`/dev/cu.foo`, `C:\Windows\…`), empty hosts, non-integer ports,
|
||||
and out-of-range ports raise `ConnectionError` here too.
|
||||
"""
|
||||
if endpoint.startswith(TCP_SCHEME):
|
||||
canonical = endpoint
|
||||
elif ":" in endpoint:
|
||||
canonical = f"{TCP_SCHEME}{endpoint}"
|
||||
else:
|
||||
canonical = f"{TCP_SCHEME}{endpoint}:{DEFAULT_TCP_PORT}"
|
||||
host, port = parse_tcp_port(canonical)
|
||||
return f"{TCP_SCHEME}{host}:{port}"
|
||||
|
||||
|
||||
def reject_if_tcp(port: str | None, tool_name: str) -> None:
|
||||
"""Raise if `port` is a TCP endpoint — for tools that need real USB
|
||||
hardware (flash, bootloader, vendor escape hatches, serial monitor).
|
||||
|
||||
Only checks the explicit arg; auto-selection via env var is the caller's
|
||||
responsibility to handle if it matters.
|
||||
"""
|
||||
if is_tcp_port(port):
|
||||
raise ConnectionError(
|
||||
f"{tool_name} is not applicable to TCP/native nodes ({port}). "
|
||||
"This tool requires USB-attached hardware."
|
||||
)
|
||||
|
||||
|
||||
def resolve_port(port: str | None) -> str:
|
||||
"""Pick a port: explicit > sole likely_meshtastic candidate > error.
|
||||
|
||||
A `tcp://` string passes through (after canonicalization). When `port`
|
||||
is None and no USB candidates are present, `MESHTASTIC_MCP_TCP_HOST`
|
||||
is consulted via `devices.list_devices()`.
|
||||
"""
|
||||
"""Pick a port: explicit > sole likely_meshtastic candidate > error."""
|
||||
if port:
|
||||
if is_tcp_port(port):
|
||||
return normalize_tcp_endpoint(port)
|
||||
return port
|
||||
candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]]
|
||||
if not candidates:
|
||||
raise ConnectionError(
|
||||
"No Meshtastic devices detected. Plug one in, set "
|
||||
f"{TCP_HOST_ENV}=<host[:port]> for a meshtasticd daemon, "
|
||||
"or pass `port` explicitly. Run `list_devices` with "
|
||||
"include_unknown=True to see all serial ports."
|
||||
"No Meshtastic devices detected. Plug one in or pass `port` explicitly. "
|
||||
"Run `list_devices` with include_unknown=True to see all serial ports."
|
||||
)
|
||||
if len(candidates) > 1:
|
||||
ports = ", ".join(c["port"] for c in candidates)
|
||||
@@ -138,62 +43,17 @@ def resolve_port(port: str | None) -> str:
|
||||
|
||||
@contextmanager
|
||||
def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
"""Open a meshtastic interface (serial or TCP) and always close it.
|
||||
"""Open a `meshtastic.SerialInterface` and always close it.
|
||||
|
||||
For serial: raises `ConnectionError` immediately if another serial
|
||||
session holds the port (a `pio device monitor` in `serial_sessions/`).
|
||||
For TCP: no exclusive-access requirement, so the serial-session check
|
||||
is skipped — but the `port_lock` still serializes parallel `connect()`
|
||||
calls to the same daemon endpoint.
|
||||
|
||||
`timeout_s` is plumbed through to both `SerialInterface(timeout=...)`
|
||||
and `TCPInterface(timeout=...)`. The meshtastic library uses the value
|
||||
as the reply-wait deadline for `localNode.waitForConfig()` during
|
||||
construction and for any subsequent admin RPC. `int()`-converted at
|
||||
the boundary because the upstream API expects whole seconds.
|
||||
Raises `ConnectionError` immediately if another serial session holds the
|
||||
port (a `pio device monitor` in `serial_sessions/`, for instance).
|
||||
"""
|
||||
resolved = resolve_port(port)
|
||||
timeout = int(timeout_s)
|
||||
|
||||
if is_tcp_port(resolved):
|
||||
from meshtastic.tcp_interface import (
|
||||
TCPInterface, # type: ignore[import-untyped]
|
||||
)
|
||||
|
||||
host, tcp_port = parse_tcp_port(resolved)
|
||||
lock = registry.port_lock(resolved)
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ConnectionError(
|
||||
f"TCP endpoint {resolved} is busy — another device operation "
|
||||
"is in flight. Retry shortly."
|
||||
)
|
||||
|
||||
iface = None
|
||||
try:
|
||||
iface = TCPInterface(
|
||||
hostname=host,
|
||||
portNumber=tcp_port,
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
yield iface
|
||||
finally:
|
||||
if iface is not None:
|
||||
try:
|
||||
iface.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lock.release()
|
||||
except RuntimeError:
|
||||
pass
|
||||
return
|
||||
|
||||
from meshtastic.serial_interface import (
|
||||
SerialInterface, # type: ignore[import-untyped]
|
||||
)
|
||||
|
||||
resolved = resolve_port(port)
|
||||
|
||||
active = registry.active_session_for_port(resolved)
|
||||
if active is not None:
|
||||
raise ConnectionError(
|
||||
@@ -210,12 +70,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
|
||||
iface = None
|
||||
try:
|
||||
iface = SerialInterface(
|
||||
devPath=resolved,
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False)
|
||||
yield iface
|
||||
finally:
|
||||
if iface is not None:
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"""USB/serial + TCP device discovery.
|
||||
"""USB/serial device discovery.
|
||||
|
||||
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
|
||||
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
|
||||
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
|
||||
a Meshtastic device" signal.
|
||||
|
||||
If `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` is set, a synthetic entry for the
|
||||
`meshtasticd` daemon at that endpoint is prepended to the result, so
|
||||
`resolve_port(None)` auto-selects it like a USB candidate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from serial.tools import list_ports
|
||||
@@ -24,45 +19,6 @@ def _to_hex(value: int | None) -> str | None:
|
||||
return f"0x{value:04x}"
|
||||
|
||||
|
||||
def _tcp_endpoint_from_env() -> dict[str, Any] | None:
|
||||
"""Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set.
|
||||
|
||||
If the env var is malformed (non-integer port, path-like host, etc.),
|
||||
return an entry with `likely_meshtastic=False` and the parser error in
|
||||
the description, rather than raising — `list_devices` is the diagnostic
|
||||
tool a user reaches for when their env var isn't working, so it must
|
||||
not crash on misconfiguration.
|
||||
"""
|
||||
host = os.environ.get("MESHTASTIC_MCP_TCP_HOST")
|
||||
if not host:
|
||||
return None
|
||||
# Lazy import to avoid a circular dependency (connection imports devices).
|
||||
from . import connection
|
||||
|
||||
try:
|
||||
port = connection.normalize_tcp_endpoint(host)
|
||||
description = "meshtasticd (TCP)"
|
||||
likely = True
|
||||
except connection.ConnectionError as e:
|
||||
# Surface the raw env-var value plus the parser's reason so the
|
||||
# user can see exactly what they set and why it was rejected.
|
||||
# Don't double the scheme if the user already prefixed `tcp://`.
|
||||
port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}"
|
||||
description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}"
|
||||
likely = False
|
||||
return {
|
||||
"port": port,
|
||||
"vid": None,
|
||||
"pid": None,
|
||||
"description": description,
|
||||
"manufacturer": None,
|
||||
"product": None,
|
||||
"serial_number": None,
|
||||
"likely_meshtastic": likely,
|
||||
"blacklisted": False,
|
||||
}
|
||||
|
||||
|
||||
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||
"""Return enriched info for serial ports, flagging Meshtastic candidates.
|
||||
|
||||
@@ -114,22 +70,6 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||
}
|
||||
)
|
||||
|
||||
# Append the TCP endpoint (if env var set) and sort everything together.
|
||||
tcp_entry = _tcp_endpoint_from_env()
|
||||
if tcp_entry is not None:
|
||||
results.append(tcp_entry)
|
||||
|
||||
# Stable ordering: likely_meshtastic first; within rank, TCP wins over
|
||||
# USB (explicit env-var configuration takes precedence over USB
|
||||
# enumeration); then by port path. A misconfigured TCP entry has
|
||||
# likely_meshtastic=False and lands among the other ignored entries —
|
||||
# it does NOT pre-empt real USB devices at the top of the list.
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
not r["likely_meshtastic"],
|
||||
not r["port"].startswith("tcp://"),
|
||||
r["port"],
|
||||
)
|
||||
)
|
||||
|
||||
# Stable ordering: likely_meshtastic first, then by port path
|
||||
results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"]))
|
||||
return results
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any
|
||||
|
||||
import serial
|
||||
|
||||
from . import boards, config, connection, devices, pio, userprefs
|
||||
from . import boards, config, devices, pio, userprefs
|
||||
|
||||
# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
|
||||
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
|
||||
@@ -46,18 +46,6 @@ def _require_confirm(confirm: bool, operation: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _reject_native_env(env: str, operation: str) -> None:
|
||||
"""`native*` envs build a host executable, not firmware — there's no
|
||||
upload step. The user wants `build` (or just runs the binary directly).
|
||||
"""
|
||||
if env.startswith("native"):
|
||||
raise FlashError(
|
||||
f"{operation} is not applicable for env {env!r}: native envs "
|
||||
"produce a host executable, not flashable firmware. Use `build` "
|
||||
"instead, then run the resulting binary directly."
|
||||
)
|
||||
|
||||
|
||||
def _artifacts_for(env: str) -> list[Path]:
|
||||
build_dir = config.firmware_root() / ".pio" / "build" / env
|
||||
if not build_dir.is_dir():
|
||||
@@ -153,8 +141,6 @@ def flash(
|
||||
that pio performs will pick up the injected values.
|
||||
"""
|
||||
_require_confirm(confirm, "flash")
|
||||
_reject_native_env(env, "flash")
|
||||
connection.reject_if_tcp(port, "flash")
|
||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||
result = pio.run(
|
||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||
@@ -214,7 +200,6 @@ def erase_and_flash(
|
||||
in that case) since a cached factory.bin would not reflect the new prefs.
|
||||
"""
|
||||
_require_confirm(confirm, "erase_and_flash")
|
||||
connection.reject_if_tcp(port, "erase_and_flash")
|
||||
_check_esp32_env(env)
|
||||
|
||||
if userprefs_overrides and skip_build:
|
||||
@@ -272,7 +257,6 @@ def update_flash(
|
||||
overrides are provided we always force a rebuild.
|
||||
"""
|
||||
_require_confirm(confirm, "update_flash")
|
||||
connection.reject_if_tcp(port, "update_flash")
|
||||
_check_esp32_env(env)
|
||||
|
||||
if userprefs_overrides and skip_build:
|
||||
@@ -407,7 +391,6 @@ def touch_1200bps(
|
||||
|
||||
Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`.
|
||||
"""
|
||||
connection.reject_if_tcp(port, "touch_1200bps")
|
||||
before_list = devices.list_devices(include_unknown=True)
|
||||
before_ports = {d["port"] for d in before_list}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from . import config, connection, pio
|
||||
from . import config, pio
|
||||
|
||||
_TIMEOUT_SHORT = 30
|
||||
_TIMEOUT_LONG = 600
|
||||
@@ -102,7 +102,6 @@ def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||
connection.reject_if_tcp(port, "esptool_chip_info")
|
||||
binary = config.esptool_bin()
|
||||
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
|
||||
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
|
||||
@@ -117,7 +116,6 @@ def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
|
||||
"""Full-chip erase. Leaves the device unbootable until reflashed."""
|
||||
_require_confirm(confirm, "esptool_erase_flash")
|
||||
connection.reject_if_tcp(port, "esptool_erase_flash")
|
||||
binary = config.esptool_bin()
|
||||
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
|
||||
# first; if it fails with unknown command, retry old.
|
||||
@@ -136,7 +134,6 @@ def esptool_raw(
|
||||
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
|
||||
if not args:
|
||||
raise ToolError("args must not be empty")
|
||||
connection.reject_if_tcp(port, "esptool_raw")
|
||||
# Find the first non-flag arg (the subcommand).
|
||||
subcommand = next((a for a in args if not a.startswith("-")), None)
|
||||
if subcommand and subcommand.replace("-", "_") in {
|
||||
@@ -159,7 +156,6 @@ NRFUTIL_DESTRUCTIVE = {"dfu", "settings"}
|
||||
|
||||
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||
_require_confirm(confirm, "nrfutil_dfu")
|
||||
connection.reject_if_tcp(port, "nrfutil_dfu")
|
||||
pkg = Path(package_path).expanduser()
|
||||
if not pkg.is_file():
|
||||
raise ToolError(f"Package not found: {pkg}")
|
||||
@@ -217,7 +213,6 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]:
|
||||
def picotool_info(port: str | None = None) -> dict[str, Any]:
|
||||
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
|
||||
only — picotool auto-detects."""
|
||||
connection.reject_if_tcp(port, "picotool_info")
|
||||
binary = config.picotool_bin()
|
||||
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
||||
if res["exit_code"] != 0:
|
||||
|
||||
@@ -71,10 +71,6 @@ def open_session(
|
||||
If `env` is supplied, pio resolves baud and filters from platformio.ini.
|
||||
Otherwise uses the supplied `baud` and `filters` (default `['direct']`).
|
||||
"""
|
||||
# Lazy import to avoid circular: registry imports serial_session.
|
||||
from . import connection
|
||||
|
||||
connection.reject_if_tcp(port, "serial_open")
|
||||
args = ["device", "monitor", "--port", port, "--no-reconnect"]
|
||||
effective_filters: list[str]
|
||||
effective_baud: int = baud
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
"""TCP transport plumbing in connection.py + devices.py.
|
||||
|
||||
Pure-Python tests — no real device or daemon required. Mocks `TCPInterface`
|
||||
when exercising `connect()`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import connection, devices
|
||||
|
||||
# ---------- helpers --------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsTcpPort:
|
||||
def test_tcp_scheme(self) -> None:
|
||||
assert connection.is_tcp_port("tcp://localhost") is True
|
||||
assert connection.is_tcp_port("tcp://localhost:4403") is True
|
||||
assert connection.is_tcp_port("tcp://192.168.1.50:9999") is True
|
||||
|
||||
def test_serial_paths(self) -> None:
|
||||
assert connection.is_tcp_port("/dev/cu.usbmodem1234") is False
|
||||
assert connection.is_tcp_port("/dev/ttyUSB0") is False
|
||||
assert connection.is_tcp_port("COM3") is False
|
||||
|
||||
def test_empty_or_none(self) -> None:
|
||||
assert connection.is_tcp_port(None) is False
|
||||
assert connection.is_tcp_port("") is False
|
||||
|
||||
|
||||
class TestParseTcpPort:
|
||||
def test_default_port(self) -> None:
|
||||
assert connection.parse_tcp_port("tcp://localhost") == ("localhost", 4403)
|
||||
|
||||
def test_explicit_port(self) -> None:
|
||||
assert connection.parse_tcp_port("tcp://localhost:9999") == (
|
||||
"localhost",
|
||||
9999,
|
||||
)
|
||||
|
||||
def test_ip_with_port(self) -> None:
|
||||
assert connection.parse_tcp_port("tcp://192.168.1.50:4403") == (
|
||||
"192.168.1.50",
|
||||
4403,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeTcpEndpoint:
|
||||
def test_bare_host(self) -> None:
|
||||
assert connection.normalize_tcp_endpoint("localhost") == "tcp://localhost:4403"
|
||||
|
||||
def test_host_port(self) -> None:
|
||||
assert (
|
||||
connection.normalize_tcp_endpoint("localhost:5000")
|
||||
== "tcp://localhost:5000"
|
||||
)
|
||||
|
||||
def test_full_url(self) -> None:
|
||||
assert (
|
||||
connection.normalize_tcp_endpoint("tcp://1.2.3.4") == "tcp://1.2.3.4:4403"
|
||||
)
|
||||
assert (
|
||||
connection.normalize_tcp_endpoint("tcp://1.2.3.4:9999")
|
||||
== "tcp://1.2.3.4:9999"
|
||||
)
|
||||
|
||||
def test_idempotent(self) -> None:
|
||||
once = connection.normalize_tcp_endpoint("localhost:4403")
|
||||
twice = connection.normalize_tcp_endpoint(once)
|
||||
assert once == twice == "tcp://localhost:4403"
|
||||
|
||||
def test_path_like_endpoint_rejected(self) -> None:
|
||||
# Serial port paths and Windows drive paths are common config typos
|
||||
# (someone passes a serial path to MESHTASTIC_MCP_TCP_HOST). Reject
|
||||
# rather than producing a nonsense `tcp:///dev/cu.foo:4403` URL.
|
||||
with pytest.raises(connection.ConnectionError, match="path separator"):
|
||||
connection.normalize_tcp_endpoint("/dev/cu.foo")
|
||||
with pytest.raises(connection.ConnectionError):
|
||||
connection.normalize_tcp_endpoint("tcp:///dev/cu.foo:4403")
|
||||
with pytest.raises(connection.ConnectionError):
|
||||
connection.normalize_tcp_endpoint(r"C:\Windows\System32")
|
||||
|
||||
def test_non_integer_port_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="not an integer"):
|
||||
connection.normalize_tcp_endpoint("tcp://host:notaport")
|
||||
with pytest.raises(connection.ConnectionError, match="not an integer"):
|
||||
connection.normalize_tcp_endpoint("host:notaport")
|
||||
|
||||
def test_empty_host_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="empty host"):
|
||||
connection.normalize_tcp_endpoint("tcp://:4403")
|
||||
|
||||
def test_port_out_of_range_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.normalize_tcp_endpoint("tcp://host:0")
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.normalize_tcp_endpoint("tcp://host:65536")
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.normalize_tcp_endpoint("host:99999")
|
||||
|
||||
|
||||
class TestParseTcpPortValidation:
|
||||
def test_missing_scheme_rejected(self) -> None:
|
||||
# parse_tcp_port is a low-level helper that requires the scheme.
|
||||
# Misuse should fail loudly rather than silently mis-parsing.
|
||||
with pytest.raises(connection.ConnectionError, match="expected"):
|
||||
connection.parse_tcp_port("localhost:4403")
|
||||
|
||||
def test_negative_port_rejected(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
||||
connection.parse_tcp_port("tcp://host:-1")
|
||||
|
||||
|
||||
# ---------- reject_if_tcp --------------------------------------------------
|
||||
|
||||
|
||||
class TestRejectIfTcp:
|
||||
def test_rejects_tcp(self) -> None:
|
||||
with pytest.raises(connection.ConnectionError, match="not applicable"):
|
||||
connection.reject_if_tcp("tcp://localhost", "esptool_chip_info")
|
||||
|
||||
def test_passes_through_serial(self) -> None:
|
||||
connection.reject_if_tcp("/dev/cu.usbmodem1", "esptool_chip_info") # no raise
|
||||
|
||||
def test_passes_through_none(self) -> None:
|
||||
# None means "auto-detect"; not the explicit-arg case we guard.
|
||||
connection.reject_if_tcp(None, "esptool_chip_info") # no raise
|
||||
|
||||
|
||||
# ---------- resolve_port ---------------------------------------------------
|
||||
|
||||
|
||||
class TestResolvePort:
|
||||
def test_explicit_serial_passthrough(self) -> None:
|
||||
assert connection.resolve_port("/dev/cu.usbmodem999") == "/dev/cu.usbmodem999"
|
||||
|
||||
def test_explicit_tcp_normalized(self) -> None:
|
||||
assert connection.resolve_port("tcp://localhost") == "tcp://localhost:4403"
|
||||
|
||||
def test_no_port_no_devices_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
with patch.object(devices, "list_devices", return_value=[]):
|
||||
with pytest.raises(
|
||||
connection.ConnectionError, match="No Meshtastic devices"
|
||||
):
|
||||
connection.resolve_port(None)
|
||||
|
||||
def test_no_port_one_candidate_selected(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
fake = [{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}]
|
||||
with patch.object(devices, "list_devices", return_value=fake):
|
||||
assert connection.resolve_port(None) == "/dev/cu.usbmodem1"
|
||||
|
||||
def test_no_port_multiple_candidates_errors(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
fake = [
|
||||
{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True},
|
||||
{"port": "/dev/cu.usbmodem2", "likely_meshtastic": True},
|
||||
]
|
||||
with patch.object(devices, "list_devices", return_value=fake):
|
||||
with pytest.raises(connection.ConnectionError, match="Multiple"):
|
||||
connection.resolve_port(None)
|
||||
|
||||
def test_env_var_surfaces_tcp_via_devices(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
||||
# Don't patch list_devices — let the real env-var path run, but stub
|
||||
# the USB enumeration to keep the test hermetic.
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
assert connection.resolve_port(None) == "tcp://localhost:4403"
|
||||
|
||||
|
||||
# ---------- devices.list_devices TCP entry --------------------------------
|
||||
|
||||
|
||||
class TestDevicesTcpEntry:
|
||||
def test_no_env_var_no_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices()
|
||||
assert all(not d["port"].startswith("tcp://") for d in ds)
|
||||
|
||||
def test_env_var_adds_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "myhost:9999")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices()
|
||||
tcp = [d for d in ds if d["port"].startswith("tcp://")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["port"] == "tcp://myhost:9999"
|
||||
assert tcp[0]["likely_meshtastic"] is True
|
||||
assert tcp[0]["description"] == "meshtasticd (TCP)"
|
||||
|
||||
def test_tcp_entry_first_in_results(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices()
|
||||
assert ds, "expected at least the TCP entry"
|
||||
assert ds[0]["port"].startswith("tcp://")
|
||||
|
||||
def test_invalid_env_var_does_not_break_list_devices(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `list_devices` is the diagnostic tool reached for when an env var
|
||||
# isn't working — it must not throw on misconfiguration.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["likely_meshtastic"] is False
|
||||
assert "invalid MESHTASTIC_MCP_TCP_HOST" in tcp[0]["description"]
|
||||
assert "not an integer" in tcp[0]["description"]
|
||||
|
||||
def test_invalid_env_var_excluded_from_resolve_port_autodetect(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `likely_meshtastic=False` keeps the bad TCP entry out of the
|
||||
# auto-select path — `resolve_port(None)` should still report
|
||||
# "no Meshtastic devices" rather than picking a broken endpoint.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
with pytest.raises(connection.ConnectionError, match="No Meshtastic"):
|
||||
connection.resolve_port(None)
|
||||
|
||||
def test_invalid_env_var_does_not_double_tcp_scheme(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# If a user mistakenly sets `MESHTASTIC_MCP_TCP_HOST=tcp://host:bad`,
|
||||
# the diagnostic entry must surface the raw value as-is rather than
|
||||
# producing `tcp://tcp://host:bad`.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "tcp://host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["port"] == "tcp://host:notaport"
|
||||
assert "tcp://tcp://" not in tcp[0]["port"]
|
||||
|
||||
def test_invalid_env_var_does_not_pre_empt_real_usb_devices(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Sort ordering: a misconfigured TCP env var must NOT take position 0
|
||||
# ahead of real USB candidates. Position 0 is reserved for the highest
|
||||
# rank (likely_meshtastic=True), with TCP-before-USB as a tiebreaker
|
||||
# within rank.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
|
||||
# Stub a USB Meshtastic candidate (Espressif VID, port present in
|
||||
# findPorts).
|
||||
class FakeInfo:
|
||||
def __init__(self, device: str, vid: int, pid: int) -> None:
|
||||
self.device = device
|
||||
self.vid = vid
|
||||
self.pid = pid
|
||||
self.description = "Heltec V3"
|
||||
self.manufacturer = "Espressif"
|
||||
self.product = "USB JTAG/serial"
|
||||
self.serial_number = "abc"
|
||||
|
||||
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
|
||||
with patch(
|
||||
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
|
||||
), patch(
|
||||
"meshtastic.util.findPorts",
|
||||
return_value=["/dev/cu.usbmodem4201"],
|
||||
):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
|
||||
assert ds, "expected at least the USB + TCP entries"
|
||||
# Real USB candidate must be at position 0 — it's likely_meshtastic.
|
||||
assert ds[0]["port"] == "/dev/cu.usbmodem4201"
|
||||
assert ds[0]["likely_meshtastic"] is True
|
||||
# The malformed TCP entry exists but lands among the unlikely entries.
|
||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
||||
assert len(tcp) == 1
|
||||
assert tcp[0]["likely_meshtastic"] is False
|
||||
assert ds.index(tcp[0]) > 0
|
||||
|
||||
def test_likely_tcp_entry_wins_tiebreak_over_usb(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Conversely, a *valid* TCP env var should sort ahead of USB
|
||||
# candidates of equal likely_meshtastic rank — explicit env-var
|
||||
# configuration is a precedence signal.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403")
|
||||
|
||||
class FakeInfo:
|
||||
def __init__(self, device: str, vid: int, pid: int) -> None:
|
||||
self.device = device
|
||||
self.vid = vid
|
||||
self.pid = pid
|
||||
self.description = "Heltec V3"
|
||||
self.manufacturer = "Espressif"
|
||||
self.product = "USB JTAG/serial"
|
||||
self.serial_number = "abc"
|
||||
|
||||
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
|
||||
with patch(
|
||||
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
|
||||
), patch(
|
||||
"meshtastic.util.findPorts",
|
||||
return_value=["/dev/cu.usbmodem4201"],
|
||||
):
|
||||
ds = devices.list_devices()
|
||||
|
||||
assert ds[0]["port"] == "tcp://localhost:4403"
|
||||
assert ds[0]["likely_meshtastic"] is True
|
||||
|
||||
|
||||
# ---------- connect() routing ---------------------------------------------
|
||||
|
||||
|
||||
class TestConnectRoutesTcp:
|
||||
def test_connect_uses_tcp_interface_for_tcp_port(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Verify the TCP branch instantiates `TCPInterface(hostname, portNumber)`
|
||||
and never touches `SerialInterface`."""
|
||||
# Make sure the env var doesn't leak in and confuse resolve_port.
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp, patch(
|
||||
"meshtastic.serial_interface.SerialInterface"
|
||||
) as mock_serial:
|
||||
mock_tcp.return_value.close.return_value = None
|
||||
with connection.connect(port="tcp://example.com:1234", timeout_s=12.0):
|
||||
pass
|
||||
|
||||
mock_tcp.assert_called_once_with(
|
||||
hostname="example.com",
|
||||
portNumber=1234,
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=12,
|
||||
)
|
||||
mock_serial.assert_not_called()
|
||||
|
||||
def test_connect_plumbs_timeout_to_serial_interface(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Verify the serial branch also propagates `timeout_s` so callers
|
||||
passing a custom timeout to `device_info` / `list_nodes` / etc. don't
|
||||
silently get the library default."""
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
|
||||
with patch("meshtastic.serial_interface.SerialInterface") as mock_serial, patch(
|
||||
"meshtastic.tcp_interface.TCPInterface"
|
||||
) as mock_tcp:
|
||||
mock_serial.return_value.close.return_value = None
|
||||
with connection.connect(port="/dev/cu.fake", timeout_s=20.0):
|
||||
pass
|
||||
|
||||
mock_serial.assert_called_once_with(
|
||||
devPath="/dev/cu.fake",
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=20,
|
||||
)
|
||||
mock_tcp.assert_not_called()
|
||||
|
||||
def test_connect_releases_lock_on_tcp_failure(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
||||
mock_tcp.side_effect = RuntimeError("boom")
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
with connection.connect(port="tcp://locktest:4403"):
|
||||
pass
|
||||
|
||||
# Lock should be released — a second connect attempt must not fail
|
||||
# with "busy".
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
||||
mock_tcp.return_value.close.return_value = None
|
||||
with connection.connect(port="tcp://locktest:4403"):
|
||||
pass
|
||||
+5
-6
@@ -29,7 +29,6 @@ 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
|
||||
@@ -57,7 +56,9 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
||||
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
|
||||
-DMESHTASTIC_EXCLUDE_POWERMON=1
|
||||
-DMESHTASTIC_EXCLUDE_STATUS=1
|
||||
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
|
||||
-DLED_BUILTIN=-1
|
||||
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
|
||||
#-D OLED_PL=1
|
||||
#-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs
|
||||
@@ -67,7 +68,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/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip
|
||||
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/21e484f409cde18d44012caef84c244eb5ca28f3.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
|
||||
@@ -120,12 +121,12 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
||||
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||
|
||||
[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/4bf593a82100b911ff816dddf7158ffdee2114cd.zip
|
||||
https://github.com/meshtastic/device-ui/archive/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip
|
||||
|
||||
; Common libs for environmental measurements in telemetry module
|
||||
[environmental_base]
|
||||
@@ -226,8 +227,6 @@ lib_deps =
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
|
||||
+1
-1
Submodule protobufs updated: 1d6f1a71ff...4d5b500df5
@@ -4,8 +4,7 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
|
||||
bool usePreset)
|
||||
{
|
||||
|
||||
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
|
||||
// rely on this being a stable literal for channel-name hashing and default-channel detection.
|
||||
// If use_preset is false, always return "Custom"
|
||||
if (!usePreset) {
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
+90
-60
@@ -79,46 +79,28 @@ bool copyFile(const char *from, const char *to)
|
||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// take SPI Lock
|
||||
spiLock->lock();
|
||||
// rename was fixed for ESP32 IDF LittleFS in April
|
||||
bool result = FSCom.rename(pathFrom, pathTo);
|
||||
spiLock->unlock();
|
||||
return result;
|
||||
#else
|
||||
return false;
|
||||
// copyFile does its own locking.
|
||||
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Platform-agnostic filesystem format / wipe.
|
||||
*
|
||||
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
|
||||
* native FSCom.format() which erases and reinitialises the LittleFS
|
||||
* partition.
|
||||
*
|
||||
* On Portduino the fs::FS backend has no format() method. We instead
|
||||
* delete /prefs (the only meshtastic data directory written at runtime)
|
||||
* and return. rmDir("/prefs") is already called unconditionally by
|
||||
* factoryReset() so this is a proven primitive on Portduino.
|
||||
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
|
||||
*
|
||||
* @return true on success, false on failure or if no filesystem is configured.
|
||||
*/
|
||||
bool fsFormat()
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
rmDir("/prefs");
|
||||
return FSBegin();
|
||||
#else
|
||||
return FSCom.format();
|
||||
#endif
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the list of files in a directory.
|
||||
*
|
||||
@@ -141,21 +123,23 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (file) {
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
||||
#ifdef ARCH_ESP32
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
|
||||
#else
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
|
||||
#endif
|
||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
||||
#ifdef ARCH_ESP32
|
||||
strcpy(fileInfo.file_name, file.path());
|
||||
#else
|
||||
strcpy(fileInfo.file_name, file.name());
|
||||
#endif
|
||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
@@ -179,59 +163,98 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
char buffer[255];
|
||||
#endif
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root || !root.isDirectory())
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
while (
|
||||
file &&
|
||||
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
listDir(filepath, levels - 1, del);
|
||||
#ifdef ARCH_ESP32
|
||||
listDir(file.path(), levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
LOG_DEBUG("Remove %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(file.name(), levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (directory)", file.name());
|
||||
listDir(file.name(), levels - 1, del);
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
LOG_DEBUG("Delete %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
const char *rootpath = root.path();
|
||||
#else
|
||||
const char *rootpath = root.name();
|
||||
#endif
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", rootpath);
|
||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
LOG_DEBUG("Remove %s", root.path());
|
||||
strncpy(buffer, root.path(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.name());
|
||||
strncpy(buffer, root.name(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#else
|
||||
root.close();
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -245,7 +268,14 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
void rmDir(const char *dirname)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(dirname, 10, true);
|
||||
#elif defined(ARCH_NRF52)
|
||||
// nRF52 implementation of LittleFS has a recursive delete function
|
||||
FSCom.rmdir_r(dirname);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ void fsInit();
|
||||
void fsListFiles();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||
void rmDir(const char *dirname);
|
||||
|
||||
+87
-182
@@ -40,22 +40,6 @@
|
||||
#include "concurrency/LockGuard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
|
||||
#include "stm32yyxx_ll_adc.h"
|
||||
|
||||
/* Analog read resolution */
|
||||
#if defined(LL_ADC_RESOLUTION_12B)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_RESOLUTION_12B
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#elif defined(LL_ADC_DS_DATA_WIDTH_12_BIT)
|
||||
#define LL_ADC_RESOLUTION LL_ADC_DS_DATA_WIDTH_12_BIT
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#else
|
||||
#error "ADC resolution could not be defined!"
|
||||
#endif
|
||||
#define ADC_RANGE (1 << BATTERY_SENSE_RESOLUTION_BITS)
|
||||
#endif
|
||||
|
||||
#if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
#include "target_specific.h"
|
||||
@@ -94,31 +78,16 @@ 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
|
||||
#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
|
||||
static const uint8_t ext_chrg_detect_mode = INPUT;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_mode = EXT_CHRG_DETECT_MODE;
|
||||
#endif
|
||||
#ifndef EXT_CHRG_DETECT_VALUE
|
||||
#define EXT_CHRG_DETECT_VALUE HIGH
|
||||
static const uint8_t ext_chrg_detect_value = HIGH;
|
||||
#else
|
||||
static const uint8_t ext_chrg_detect_value = EXT_CHRG_DETECT_VALUE;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -359,17 +328,11 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_STM32WL
|
||||
// STM32 ADC with VREFINT runtime calibration
|
||||
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
|
||||
raw = analogRead(BATTERY_PIN);
|
||||
scaled = __LL_ADC_CALC_DATA_TO_VOLTAGE(Vref, raw, LL_ADC_RESOLUTION);
|
||||
scaled *= operativeAdcMultiplier;
|
||||
#elif defined(ARCH_ESP32) // ADC block for espressif platforms
|
||||
#ifdef ARCH_ESP32 // ADC block for espressif platforms
|
||||
raw = espAdcRead();
|
||||
scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs);
|
||||
scaled *= operativeAdcMultiplier;
|
||||
#else // block for all other platforms
|
||||
#else // block for all other platforms
|
||||
#ifdef ARCH_NRF52
|
||||
concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock);
|
||||
#endif
|
||||
@@ -484,14 +447,28 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; }
|
||||
#endif
|
||||
|
||||
// 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.
|
||||
/// 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
|
||||
virtual bool isVbusIn() override
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
|
||||
#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;
|
||||
|
||||
// technically speaking this should work for all(?) NRF52 boards
|
||||
// but needs testing across multiple devices. NRF52 USB would not even work if
|
||||
@@ -512,9 +489,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
|
||||
@@ -553,11 +530,6 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
bool initial_read_done = false;
|
||||
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
||||
uint32_t last_read_time_ms = 0;
|
||||
#ifdef ARCH_STM32WL
|
||||
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
|
||||
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
|
||||
uint32_t Vref = 3300;
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT)
|
||||
|
||||
@@ -647,10 +619,14 @@ Power::Power() : OSThread("Power")
|
||||
bool Power::analogInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
|
||||
pinMode(EXT_PWR_DETECT, INPUT_PULLUP);
|
||||
#else
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
#endif
|
||||
#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
|
||||
@@ -663,9 +639,7 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
|
||||
#ifdef ARCH_ESP32 // ESP32 needs special analog stuff
|
||||
|
||||
#ifndef ADC_WIDTH // max resolution by default
|
||||
static const adc_bits_width_t width = ADC_WIDTH_BIT_12;
|
||||
@@ -675,7 +649,7 @@ bool Power::analogInit()
|
||||
#ifndef BAT_MEASURE_ADC_UNIT // ADC1
|
||||
adc1_config_width(width);
|
||||
adc1_config_channel_atten(adc_channel, atten);
|
||||
#else // ADC2
|
||||
#else // ADC2
|
||||
adc2_config_channel_atten(adc_channel, atten);
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32S3
|
||||
// ADC2 wifi bug workaround
|
||||
@@ -705,7 +679,7 @@ bool Power::analogInit()
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
#ifndef ARCH_ESP32
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
@@ -743,17 +717,37 @@ bool Power::setup()
|
||||
found = true;
|
||||
#endif
|
||||
}
|
||||
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
|
||||
enabled = found;
|
||||
low_voltage_counter = 0;
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Register callbacks for before and after lightsleep
|
||||
// Used to detach and reattach interrupts
|
||||
lsObserver.observe(¬ifyLightSleep);
|
||||
lsEndObserver.observe(¬ifyLightSleepEnd);
|
||||
#endif
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -781,10 +775,8 @@ void Power::reboot()
|
||||
rp2040.reboot();
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
deInitApiServer();
|
||||
#ifdef __linux__
|
||||
if (aLinuxInputImpl)
|
||||
aLinuxInputImpl->deInit();
|
||||
#endif
|
||||
SPI.end();
|
||||
Wire.end();
|
||||
Serial1.end();
|
||||
@@ -1002,14 +994,6 @@ 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...
|
||||
|
||||
@@ -1026,6 +1010,13 @@ 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();
|
||||
}
|
||||
@@ -1035,97 +1026,6 @@ 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
|
||||
*
|
||||
@@ -1397,16 +1297,21 @@ bool Power::axpChipInit()
|
||||
uint64_t pmuIrqMask = 0;
|
||||
|
||||
if (PMU->getChipModel() == XPOWERS_AXP192) {
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_VBUS_REMOVE_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_BAT_INSERT_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ;
|
||||
} else if (PMU->getChipModel() == XPOWERS_AXP2101) {
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_VBUS_REMOVE_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_BAT_INSERT_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ;
|
||||
}
|
||||
|
||||
pinMode(PMU_IRQ, INPUT);
|
||||
attachInterrupt(
|
||||
PMU_IRQ, [] { pmu_irq = true; }, FALLING);
|
||||
|
||||
// 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).
|
||||
// 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
|
||||
PMU->enableIRQ(pmuIrqMask);
|
||||
|
||||
PMU->clearIrqStatus();
|
||||
@@ -1872,7 +1777,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;
|
||||
@@ -1881,7 +1786,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
|
||||
@@ -1903,10 +1808,10 @@ SerialBatteryLevel serialBatteryLevel;
|
||||
bool Power::serialBatteryInit()
|
||||
{
|
||||
#ifdef EXT_PWR_DETECT
|
||||
pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
|
||||
pinMode(EXT_PWR_DETECT, INPUT);
|
||||
#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();
|
||||
|
||||
+11
-48
@@ -58,35 +58,6 @@ 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");
|
||||
@@ -116,7 +87,6 @@ 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");
|
||||
@@ -189,8 +159,6 @@ static void lsIdle()
|
||||
static void lsExit()
|
||||
{
|
||||
LOG_POWERFSM("State: lsExit");
|
||||
// Lift the light-sleep force-off gate when leaving LS.
|
||||
t5BacklightWakeFromSleep();
|
||||
}
|
||||
|
||||
static void nbEnter()
|
||||
@@ -212,8 +180,6 @@ static void darkEnter()
|
||||
setBluetoothEnable(true);
|
||||
if (screen)
|
||||
screen->setOn(false);
|
||||
// Screen timeout enters DARK; ensure backlight also turns off.
|
||||
t5BacklightOffForTimeout();
|
||||
}
|
||||
|
||||
static void serialEnter()
|
||||
@@ -323,13 +289,12 @@ 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, 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,
|
||||
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,
|
||||
"Press"); // Allow button to work while in serial API
|
||||
|
||||
// Handle critically low power battery by forcing deep sleep
|
||||
@@ -349,13 +314,11 @@ void PowerFSM_setup()
|
||||
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
|
||||
|
||||
// Inputbroker
|
||||
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(&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(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
powerFSM.add_transition(&stateON, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
#include "memGet.h"
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||
#include <assert.h>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
@@ -21,22 +20,6 @@
|
||||
#if HAS_NETWORKING
|
||||
extern meshtastic::Syslog syslog;
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
std::atomic<bool> serialHalLogSuppressed{false};
|
||||
}
|
||||
|
||||
void RedirectablePrint::setSerialHalLogSuppressed(bool suppressed)
|
||||
{
|
||||
serialHalLogSuppressed.store(suppressed);
|
||||
}
|
||||
|
||||
bool RedirectablePrint::isSerialHalLogSuppressed()
|
||||
{
|
||||
return serialHalLogSuppressed.load();
|
||||
}
|
||||
|
||||
void RedirectablePrint::rpInit()
|
||||
{
|
||||
#ifdef HAS_FREE_RTOS
|
||||
@@ -154,7 +137,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, millis() % 1000);
|
||||
::printf("| %02d:%02d:%02d %u ", hour, min, sec, millis() / 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
@@ -168,7 +151,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format,
|
||||
if (color) {
|
||||
::printf("\u001b[0m");
|
||||
}
|
||||
::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000);
|
||||
::printf("| ??:??:?? %u ", millis() / 1000);
|
||||
#else
|
||||
printf("%s ", logLevel);
|
||||
if (color) {
|
||||
@@ -294,10 +277,6 @@ meshtastic_LogRecord_Level RedirectablePrint::getLogLevel(const char *logLevel)
|
||||
void RedirectablePrint::log(const char *logLevel, const char *format, ...)
|
||||
{
|
||||
|
||||
if (isSerialHalLogSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// append \n to format
|
||||
size_t len = strlen(format);
|
||||
auto newFormat = std::unique_ptr<char[]>(new char[len + 2]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,11 +24,6 @@ class RedirectablePrint : public Print
|
||||
public:
|
||||
explicit RedirectablePrint(Print *_dest) : dest(_dest) {}
|
||||
|
||||
/// Suppress all log output while a SerialHal transaction is in progress.
|
||||
// Unclear if this is necessary, but it seems to help with response speeds.
|
||||
static void setSerialHalLogSuppressed(bool suppressed);
|
||||
static bool isSerialHalLogSuppressed();
|
||||
|
||||
/**
|
||||
* Set a new destination
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,10 @@ static File openFile(const char *filename, bool fullAtomic)
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
||||
#ifdef ARCH_NRF52
|
||||
FSCom.remove(filename);
|
||||
return FSCom.open(filename, FILE_O_WRITE);
|
||||
#endif
|
||||
if (!fullAtomic) {
|
||||
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
||||
}
|
||||
@@ -63,6 +67,9 @@ bool SafeFile::close()
|
||||
f.close();
|
||||
spiLock->unlock();
|
||||
|
||||
#ifdef ARCH_NRF52
|
||||
return true;
|
||||
#endif
|
||||
if (!testReadback())
|
||||
return false;
|
||||
|
||||
|
||||
@@ -30,9 +30,6 @@ SerialConsole *console;
|
||||
|
||||
void consoleInit()
|
||||
{
|
||||
if (console) {
|
||||
return;
|
||||
}
|
||||
auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread
|
||||
|
||||
#if defined(SERIAL_HAS_ON_RECEIVE)
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel.
|
||||
|
||||
TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
TX_LOG + RX_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.
|
||||
*/
|
||||
|
||||
+3
-13
@@ -78,11 +78,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Pre-hop drop handling (compile-time flag).
|
||||
#ifndef MESHTASTIC_PREHOP_DROP
|
||||
#define MESHTASTIC_PREHOP_DROP 1
|
||||
#endif
|
||||
|
||||
/// Convert a preprocessor name into a quoted string
|
||||
#define xstr(s) ystr(s)
|
||||
#define ystr(s) #s
|
||||
@@ -162,9 +157,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#elif defined(HELTEC_MESH_NODE_T096)
|
||||
#define NUM_PA_POINTS 22
|
||||
#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7
|
||||
#elif defined(HELTEC_V4_R8)
|
||||
#define NUM_PA_POINTS 22
|
||||
#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7
|
||||
#else
|
||||
// If a board enables USE_KCT8103L_PA but does not match a known variant and has
|
||||
// not already provided a PA curve, fail at compile time to avoid unsafe defaults.
|
||||
@@ -234,7 +226,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define BME_ADDR 0x76
|
||||
#define BME_ADDR_ALTERNATE 0x77
|
||||
#define MCP9808_ADDR 0x18
|
||||
#define INA_ADDR 0x40 // same as SHT2X
|
||||
#define INA_ADDR 0x40
|
||||
#define INA_ADDR_ALTERNATE 0x41
|
||||
#define INA_ADDR_WAVESHARE_UPS 0x43
|
||||
#define INA3221_ADDR 0x42
|
||||
@@ -247,8 +239,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define LPS22HB_ADDR 0x5C
|
||||
#define LPS22HB_ADDR_ALT 0x5D
|
||||
#define SFA30_ADDR 0x5D
|
||||
#define SHTXX_ADDR 0x44
|
||||
#define SHTXX_ADDR_ALT 0x45
|
||||
#define SHT31_4x_ADDR 0x44
|
||||
#define SHT31_4x_ADDR_ALT 0x45
|
||||
#define PMSA003I_ADDR 0x12
|
||||
#define QMA6100P_ADDR 0x12
|
||||
#define AHT10_ADDR 0x38
|
||||
@@ -502,7 +494,6 @@ 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
|
||||
@@ -519,7 +510,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
|
||||
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
|
||||
#define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1
|
||||
#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1
|
||||
#define MESHTASTIC_EXCLUDE_ATAK 1
|
||||
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
|
||||
#define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1
|
||||
|
||||
@@ -34,7 +34,6 @@ class ScanI2C
|
||||
SHT31,
|
||||
SHT4X,
|
||||
SHTC3,
|
||||
SHTXX,
|
||||
LPS22HB,
|
||||
QMC6310U,
|
||||
QMC6310N,
|
||||
|
||||
@@ -136,9 +136,7 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
|
||||
return match;
|
||||
}
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
// FIXME Move to a separate file for detection of sensors that require more complex interactions?
|
||||
// For SEN5X detection
|
||||
/// for SEN5X detection
|
||||
// Note, this code needs to be called before setting the I2C bus speed
|
||||
// for the screen at high speed. The speed needs to be at 100kHz, otherwise
|
||||
// detection will not work
|
||||
@@ -176,46 +174,6 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address)
|
||||
|
||||
return String(productName);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
{
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFA);
|
||||
i2cBus->write(0x0F);
|
||||
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)8) != 8)
|
||||
return false;
|
||||
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 8) {
|
||||
i2cBus->read();
|
||||
}
|
||||
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(0xFC);
|
||||
i2cBus->write(0xC9);
|
||||
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)6) != 6)
|
||||
return false;
|
||||
|
||||
// Just flush the data
|
||||
while (i2cBus->available() < 6) {
|
||||
i2cBus->read();
|
||||
}
|
||||
|
||||
// Assume we detect the SHT21 if something came back from the request
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
||||
case ADDR: \
|
||||
@@ -413,47 +371,27 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
#endif
|
||||
#if !defined(M5STACK_UNITC6L)
|
||||
case INA_ADDR: // Same as SHT2X
|
||||
case INA_ADDR:
|
||||
case INA_ADDR_ALTERNATE:
|
||||
case INA_ADDR_WAVESHARE_UPS: {
|
||||
uint16_t mfg = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
|
||||
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);
|
||||
|
||||
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) {
|
||||
if (registerValue == 0x2260) {
|
||||
logFoundDevice("INA226", (uint8_t)addr.address);
|
||||
type = INA226;
|
||||
}
|
||||
// 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) {
|
||||
} else {
|
||||
logFoundDevice("INA260", (uint8_t)addr.address);
|
||||
type = INA260;
|
||||
}
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
|
||||
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
|
||||
type = SHTXX;
|
||||
}
|
||||
#endif
|
||||
else { // Assume INA219 if none of the above ones are found
|
||||
} else { // Assume INA219 if INA260 ID is not 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);
|
||||
@@ -510,19 +448,22 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SHTXX_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHTXX_ADDR_ALT: // same as OPT3001_ADDR
|
||||
case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT
|
||||
case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR
|
||||
if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
|
||||
type = OPT3001;
|
||||
logFoundDevice("OPT3001", (uint8_t)addr.address);
|
||||
} else { // SHTXX
|
||||
type = SHTXX;
|
||||
logFoundDevice("SHTXX", (uint8_t)addr.address);
|
||||
} else if (i2cCommandResponseLength(addr, 0x89, 6)) { // SHT4x serial number (6 bytes inc. CRC)
|
||||
type = SHT4X;
|
||||
logFoundDevice("SHT4X", (uint8_t)addr.address);
|
||||
} else {
|
||||
type = SHT31;
|
||||
logFoundDevice("SHT31", (uint8_t)addr.address);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address)
|
||||
SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address)
|
||||
case RCWL9620_ADDR:
|
||||
// get MAX30102 PARTID
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1);
|
||||
@@ -758,7 +699,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
break;
|
||||
} else {
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
String prod = "";
|
||||
prod = readSEN5xProductName(i2cBus, addr.address);
|
||||
if (prod.startsWith("SEN55")) {
|
||||
@@ -774,7 +714,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("Sensirion SEN50", addr.address);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
if (addr.address == BMX160_ADDR) {
|
||||
type = BMX160;
|
||||
logFoundDevice("BMX160", (uint8_t)addr.address);
|
||||
|
||||
@@ -103,14 +103,6 @@ static int32_t gpsSwitch()
|
||||
if (gps) {
|
||||
int currentState = digitalRead(PIN_GPS_SWITCH);
|
||||
|
||||
// Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS.
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
|
||||
gps->disable();
|
||||
lastState = currentState;
|
||||
firstrun = false;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
// if the switch is set to zero, disable the GPS Thread
|
||||
if (firstrun)
|
||||
if (currentState == LOW)
|
||||
|
||||
@@ -70,25 +70,20 @@ bool GPSUpdateScheduling::isUpdateDue()
|
||||
// Have we been searching for a GPS position for too long?
|
||||
bool GPSUpdateScheduling::searchedTooLong()
|
||||
{
|
||||
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
|
||||
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
||||
uint32_t elapsed = elapsedSearchMs();
|
||||
|
||||
// Anything over 15 minutes is too long, regardless of the broadcast interval.
|
||||
// TODO: Make a smarter algorithm that backs off the search dwell time when not getting a lock.
|
||||
if (elapsed > maxSearchClampMs)
|
||||
return true;
|
||||
|
||||
uint32_t minimumOrConfiguredSecs =
|
||||
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
|
||||
// If broadcast interval set to max, no such thing as "too long"
|
||||
if (maxSearchMs == UINT32_MAX)
|
||||
return false;
|
||||
|
||||
// If we've been searching longer than our position broadcast interval: that's too long
|
||||
if (elapsed > maxSearchMs)
|
||||
else if (elapsedSearchMs() > maxSearchMs)
|
||||
return true;
|
||||
|
||||
// Otherwise, not too long yet!
|
||||
return false;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
// Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation
|
||||
|
||||
+77
-175
@@ -39,7 +39,6 @@ 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
|
||||
@@ -55,14 +54,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "graphics/images.h"
|
||||
#include "input/TouchScreenImpl1.h"
|
||||
#include "main.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/Default.h"
|
||||
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#include "modules/TextMessageModule.h"
|
||||
@@ -71,6 +68,12 @@ 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
|
||||
@@ -95,7 +98,6 @@ namespace graphics
|
||||
|
||||
// This means the *visible* area (sh1106 can address 132, but shows 128 for example)
|
||||
#define IDLE_FRAMERATE 1 // in fps
|
||||
#define COMPASS_ACTIVE_FRAMERATE 20
|
||||
|
||||
// DEBUG
|
||||
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
|
||||
@@ -105,27 +107,6 @@ 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
|
||||
@@ -154,60 +135,6 @@ static bool heartbeat = false;
|
||||
|
||||
extern bool hasUnreadMessage;
|
||||
|
||||
static inline float wrapHeading360(float heading)
|
||||
{
|
||||
if (heading < 0.0f) {
|
||||
heading += 360.0f;
|
||||
} else if (heading >= 360.0f) {
|
||||
heading -= 360.0f;
|
||||
}
|
||||
return heading;
|
||||
}
|
||||
|
||||
void Screen::setHeading(float heading)
|
||||
{
|
||||
const float wrappedHeading = wrapHeading360(heading);
|
||||
|
||||
if (!hasCompass) {
|
||||
hasCompass = true;
|
||||
compassHeading = wrappedHeading;
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
|
||||
float delta = wrappedHeading - compassHeading;
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
|
||||
// Adaptive filtering:
|
||||
// - Strong damping for tiny deltas (jitter)
|
||||
// - Faster response for larger turns
|
||||
const float absDelta = (delta >= 0.0f) ? delta : -delta;
|
||||
if (absDelta < 1.0f) {
|
||||
return;
|
||||
}
|
||||
|
||||
float alpha = 0.35f;
|
||||
if (absDelta > 25.0f) {
|
||||
alpha = 0.85f;
|
||||
} else if (absDelta > 10.0f) {
|
||||
alpha = 0.65f;
|
||||
}
|
||||
|
||||
float step = delta * alpha;
|
||||
const float maxStep = 12.0f;
|
||||
if (step > maxStep) {
|
||||
step = maxStep;
|
||||
} else if (step < -maxStep) {
|
||||
step = -maxStep;
|
||||
}
|
||||
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// Overlay Alert Banner Renderer
|
||||
// ==============================
|
||||
@@ -244,7 +171,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);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
}
|
||||
|
||||
// Called to trigger a banner with custom message and duration
|
||||
@@ -266,7 +193,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);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
}
|
||||
|
||||
// Called to trigger a banner with custom message and duration
|
||||
@@ -290,7 +217,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);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
}
|
||||
|
||||
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
|
||||
@@ -313,7 +240,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);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
}
|
||||
|
||||
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
@@ -345,25 +272,10 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
|
||||
float Screen::estimatedHeading(double lat, double lon)
|
||||
{
|
||||
static double oldLat, oldLon;
|
||||
static float b = -1.0f;
|
||||
static uint32_t lastHeadingAtMs = 0;
|
||||
const uint32_t now = millis();
|
||||
const uint32_t gpsUpdateIntervalSecs =
|
||||
Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval);
|
||||
uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs;
|
||||
if (config.position.position_broadcast_smart_enabled) {
|
||||
const uint32_t smartMinIntervalSecs = Default::getConfiguredOrDefault(
|
||||
config.position.broadcast_smart_minimum_interval_secs, default_broadcast_smart_minimum_interval_secs);
|
||||
if (smartMinIntervalSecs > effectiveUpdateIntervalSecs) {
|
||||
effectiveUpdateIntervalSecs = smartMinIntervalSecs;
|
||||
}
|
||||
}
|
||||
// Two expected update windows; keep arithmetic 32-bit to avoid pulling in larger 64-bit helpers.
|
||||
const uint32_t headingStaleMs =
|
||||
(effectiveUpdateIntervalSecs > (UINT32_MAX / 2000U)) ? UINT32_MAX : (effectiveUpdateIntervalSecs * 2000U);
|
||||
static float b;
|
||||
|
||||
if (oldLat == 0) {
|
||||
// Need at least two position points before we can infer heading.
|
||||
// just prepare for next time
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
|
||||
@@ -371,20 +283,12 @@ float Screen::estimatedHeading(double lat, double lon)
|
||||
}
|
||||
|
||||
float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon);
|
||||
if (d < 10) { // haven't moved enough, keep previous heading (invalid until first real movement)
|
||||
if (lastHeadingAtMs != 0 && (now - lastHeadingAtMs) >= headingStaleMs) {
|
||||
// Heading is stale after prolonged no-movement; force reacquire.
|
||||
b = -1.0f;
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
}
|
||||
if (d < 10) // haven't moved enough, just keep current bearing
|
||||
return b;
|
||||
}
|
||||
|
||||
b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG;
|
||||
oldLat = lat;
|
||||
oldLon = lon;
|
||||
lastHeadingAtMs = now;
|
||||
|
||||
return b;
|
||||
}
|
||||
@@ -405,6 +309,30 @@ 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);
|
||||
@@ -467,13 +395,9 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
#endif
|
||||
|
||||
#if defined(USE_ST7789)
|
||||
// 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);
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#elif defined(USE_ST7796)
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#endif
|
||||
|
||||
ui = new OLEDDisplayUi(dispdev);
|
||||
@@ -524,11 +448,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
delay(100);
|
||||
#endif
|
||||
#if !ARCH_PORTDUINO
|
||||
#if defined(USE_ST7789) && defined(VTFT_CTRL)
|
||||
// Ensure panel power rail is enabled before sending wake commands.
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, LOW);
|
||||
#endif
|
||||
dispdev->displayOn();
|
||||
#endif
|
||||
|
||||
@@ -550,6 +469,10 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
ui->init();
|
||||
#endif
|
||||
#if defined(USE_ST7789) && defined(VTFT_LEDA)
|
||||
#ifdef VTFT_CTRL
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, LOW);
|
||||
#endif
|
||||
ui->init();
|
||||
#ifdef ESP_PLATFORM
|
||||
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
|
||||
@@ -590,22 +513,23 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
#endif
|
||||
#ifdef USE_ST7789
|
||||
SPI1.end();
|
||||
// Keep TFT control pins in deterministic states while timed-off.
|
||||
// Floating/default pin states can corrupt panel edge rows on wake.
|
||||
#if defined(ARCH_ESP32)
|
||||
#ifdef VTFT_LEDA
|
||||
pinMode(VTFT_LEDA, OUTPUT);
|
||||
digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON);
|
||||
pinMode(VTFT_LEDA, ANALOG);
|
||||
#endif
|
||||
#ifdef VTFT_CTRL
|
||||
pinMode(VTFT_CTRL, OUTPUT);
|
||||
digitalWrite(VTFT_CTRL, HIGH);
|
||||
pinMode(VTFT_CTRL, ANALOG);
|
||||
#endif
|
||||
pinMode(ST7789_RESET, ANALOG);
|
||||
pinMode(ST7789_RS, ANALOG);
|
||||
pinMode(ST7789_NSS, ANALOG);
|
||||
#else
|
||||
nrf_gpio_cfg_default(VTFT_LEDA);
|
||||
nrf_gpio_cfg_default(VTFT_CTRL);
|
||||
nrf_gpio_cfg_default(ST7789_RESET);
|
||||
nrf_gpio_cfg_default(ST7789_RS);
|
||||
nrf_gpio_cfg_default(ST7789_NSS);
|
||||
#endif
|
||||
pinMode(ST7789_RESET, OUTPUT);
|
||||
digitalWrite(ST7789_RESET, HIGH);
|
||||
pinMode(ST7789_RS, OUTPUT);
|
||||
digitalWrite(ST7789_RS, HIGH);
|
||||
pinMode(ST7789_NSS, OUTPUT);
|
||||
digitalWrite(ST7789_NSS, HIGH);
|
||||
#endif
|
||||
#ifdef USE_ST7796
|
||||
SPI1.end();
|
||||
@@ -660,16 +584,16 @@ void Screen::setup()
|
||||
static_cast<SH1106Wire *>(dispdev)->setSubtype(7);
|
||||
#endif
|
||||
|
||||
#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);
|
||||
#if defined(USE_ST7789) && defined(TFT_MESH)
|
||||
// Apply custom RGB color (e.g. Heltec T114/T190)
|
||||
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#endif
|
||||
#if defined(MUZI_BASE)
|
||||
dispdev->delayPoweron = true;
|
||||
#endif
|
||||
#if defined(USE_ST7796)
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
|
||||
#if defined(USE_ST7796) && defined(TFT_MESH)
|
||||
// Custom text color, if defined in variant.h
|
||||
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
|
||||
#endif
|
||||
|
||||
// Initialize display and UI system
|
||||
@@ -715,7 +639,7 @@ void Screen::setup()
|
||||
#endif
|
||||
{
|
||||
const char *region = myRegion ? myRegion->name : nullptr;
|
||||
graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y);
|
||||
graphics::UIRenderer::drawIconScreen(region, display, state, x, y);
|
||||
}
|
||||
};
|
||||
ui->setFrames(alertFrames, 1);
|
||||
@@ -754,9 +678,9 @@ void Screen::setup()
|
||||
// Turn on display and trigger first draw
|
||||
handleSetOn(true);
|
||||
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
#ifndef USE_EINK
|
||||
updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice
|
||||
ui->update(); // Some SSD1306 clones drop the first draw, so run twice
|
||||
#endif
|
||||
serialSinceMsec = millis();
|
||||
|
||||
@@ -829,7 +753,7 @@ void Screen::forceDisplay(bool forceUiUpdate)
|
||||
do {
|
||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||
delay(10);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||
|
||||
// Return to normal frame rate
|
||||
@@ -900,9 +824,9 @@ int32_t Screen::runOnce()
|
||||
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
|
||||
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
|
||||
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
#ifndef USE_EINK
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
#endif
|
||||
showingOEMBootScreen = false;
|
||||
}
|
||||
@@ -993,28 +917,15 @@ 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
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
|
||||
// Switch to a low framerate (to save CPU) when we are not in transition
|
||||
// but we should only call setTargetFPS when framestate changes, because
|
||||
// otherwise that breaks animations.
|
||||
|
||||
uint32_t desiredFramerate = IDLE_FRAMERATE;
|
||||
#if HAS_GPS && !defined(USE_EINK)
|
||||
if (showingNormalScreen && hasCompass) {
|
||||
const uint8_t currentFrame = ui->getUiState()->currentFrame;
|
||||
if ((framesetInfo.positions.gps != 255 && currentFrame == framesetInfo.positions.gps) ||
|
||||
(framesetInfo.positions.waypoint != 255 && currentFrame == framesetInfo.positions.waypoint) ||
|
||||
(framesetInfo.positions.firstFavorite != 255 && currentFrame >= framesetInfo.positions.firstFavorite &&
|
||||
currentFrame <= framesetInfo.positions.lastFavorite)) {
|
||||
desiredFramerate = COMPASS_ACTIVE_FRAMERATE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (targetFramerate != desiredFramerate && ui->getUiState()->frameState == FIXED) {
|
||||
if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) {
|
||||
// oldFrameState = ui->getUiState()->frameState;
|
||||
targetFramerate = desiredFramerate;
|
||||
targetFramerate = IDLE_FRAMERATE;
|
||||
|
||||
ui->setTargetFPS(targetFramerate);
|
||||
forceDisplay();
|
||||
@@ -1055,7 +966,7 @@ void Screen::setSSLFrames()
|
||||
// LOG_DEBUG("Show SSL frames");
|
||||
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
|
||||
ui->setFrames(sslFrames, 1);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,7 +1002,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
|
||||
do {
|
||||
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
|
||||
delay(1);
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
} while (ui->getUiState()->lastUpdate < startUpdate);
|
||||
|
||||
#if defined(USE_EINK_PARALLELDISPLAY)
|
||||
@@ -1286,7 +1197,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||||
if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,7 +1226,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
|
||||
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
|
||||
|
||||
prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed)
|
||||
prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed)
|
||||
|
||||
// Focus on a specific frame, in the frame set we just created
|
||||
switch (focus) {
|
||||
@@ -1466,15 +1377,9 @@ 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;
|
||||
@@ -1608,9 +1513,6 @@ 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
|
||||
@@ -1822,7 +1724,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
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1837,7 +1739,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
|
||||
updateUiFrame(ui);
|
||||
ui->update();
|
||||
|
||||
menuHandler::handleMenuSwitch(dispdev);
|
||||
return 0;
|
||||
|
||||
@@ -330,11 +330,15 @@ class Screen : public concurrency::OSThread
|
||||
|
||||
// Function to allow the AccelerometerThread to set the heading if a sensor provides it
|
||||
// Mutex needed?
|
||||
void setHeading(float heading);
|
||||
void setHeading(long _heading)
|
||||
{
|
||||
hasCompass = true;
|
||||
compassHeading = fmod(_heading, 360);
|
||||
}
|
||||
|
||||
bool hasHeading() { return hasCompass; }
|
||||
|
||||
float getHeading() { return compassHeading; }
|
||||
long getHeading() { return compassHeading; }
|
||||
|
||||
void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; }
|
||||
uint32_t getEndCalibration() { return endCalibrationAt; }
|
||||
@@ -788,4 +792,4 @@ extern std::vector<std::string> functionSymbol;
|
||||
extern std::string functionSymbolString;
|
||||
extern graphics::Screen *screen;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,20 +1,16 @@
|
||||
#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
|
||||
@@ -69,12 +65,6 @@ 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 *
|
||||
// *********************************
|
||||
@@ -95,8 +85,7 @@ 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,
|
||||
bool transparent_background, bool use_title_color_override, uint16_t title_color_override)
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date)
|
||||
{
|
||||
constexpr int HEADER_OFFSET_Y = 1;
|
||||
y += HEADER_OFFSET_Y;
|
||||
@@ -111,93 +100,30 @@ 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 (useInvertedHeaderStyle) {
|
||||
if (isInverted) {
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, 0, screenW, headerHeight);
|
||||
display->fillRect(0, 0, screenW, highlightHeight + 2);
|
||||
display->setColor(WHITE);
|
||||
drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
|
||||
display->setColor(BLACK);
|
||||
} else {
|
||||
display->setColor(BLACK);
|
||||
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->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);
|
||||
}
|
||||
}
|
||||
|
||||
#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);
|
||||
@@ -226,17 +152,6 @@ 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;
|
||||
@@ -265,15 +180,6 @@ 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 {
|
||||
@@ -288,41 +194,21 @@ 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(percentX, textY, "%");
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = percentX + percentWidth + 2;
|
||||
#endif
|
||||
display->drawString(batteryX + chargeNumWidth - 1, textY, "%");
|
||||
if (isBold) {
|
||||
display->drawString(batteryX + 1, textY, chargeStr);
|
||||
display->drawString(percentX + 1, textY, "%");
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
statusLeftEndX = percentX + percentWidth + 3;
|
||||
#endif
|
||||
display->drawString(batteryX + chargeNumWidth, textY, "%");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,9 +253,6 @@ 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;
|
||||
@@ -395,7 +278,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 (useInvertedHeaderStyle) {
|
||||
if (isInverted && !force_no_invert) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -410,7 +293,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 (useInvertedHeaderStyle) {
|
||||
if (isInverted && !force_no_invert) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -426,7 +309,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 (useInvertedHeaderStyle) {
|
||||
if (isInverted && !force_no_invert) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -440,7 +323,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 (useInvertedHeaderStyle) {
|
||||
if (isInverted && !force_no_invert) {
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
|
||||
display->setColor(BLACK);
|
||||
@@ -468,9 +351,7 @@ 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
|
||||
@@ -512,16 +393,6 @@ 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
|
||||
}
|
||||
@@ -559,23 +430,14 @@ 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, footerY, SCREEN_WIDTH, footerH);
|
||||
display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
|
||||
(connection_icon_height * scale) + (2 * scale));
|
||||
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;
|
||||
@@ -589,127 +451,65 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
}
|
||||
|
||||
} else {
|
||||
display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon);
|
||||
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
|
||||
connection_icon);
|
||||
}
|
||||
}
|
||||
|
||||
bool isAllowedPunctuation(char c)
|
||||
{
|
||||
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;
|
||||
}
|
||||
const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ ";
|
||||
return allowed.find(c) != std::string::npos;
|
||||
}
|
||||
|
||||
static inline size_t utf8CodePointLength(unsigned char lead)
|
||||
static void replaceAll(std::string &s, const std::string &from, const std::string &to)
|
||||
{
|
||||
if ((lead & 0x80) == 0x00) {
|
||||
return 1;
|
||||
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 & 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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (!inReplacement) {
|
||||
output.push_back(kReplacementChar);
|
||||
output += static_cast<char>(0xBF); // ISO-8859-1 for inverted question mark
|
||||
inReplacement = true;
|
||||
}
|
||||
i += seqLen;
|
||||
continue;
|
||||
}
|
||||
const unsigned char normalizedUc = static_cast<unsigned char>(normalized);
|
||||
if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) {
|
||||
output.push_back(normalized);
|
||||
inReplacement = false;
|
||||
} else if (!inReplacement) {
|
||||
output.push_back(kReplacementChar);
|
||||
inReplacement = true;
|
||||
}
|
||||
i += consumed;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <OLEDDisplay.h>
|
||||
#include <stdint.h>
|
||||
#include <string>
|
||||
|
||||
namespace graphics
|
||||
@@ -53,8 +52,7 @@ 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 transparent_background = false, bool use_title_color_override = false,
|
||||
uint16_t title_color_override = 0);
|
||||
bool show_date = false);
|
||||
|
||||
// Shared battery/time/mail header
|
||||
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
|
||||
|
||||
@@ -1,819 +0,0 @@
|
||||
#include "TFTColorRegions.h"
|
||||
#include "NodeDB.h"
|
||||
#include "TFTPalette.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
struct TFTRoleColorsBe {
|
||||
uint16_t onColorBe;
|
||||
uint16_t offColorBe;
|
||||
};
|
||||
|
||||
static uint8_t colorRegionCount = 0;
|
||||
static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u;
|
||||
static constexpr uint32_t kFnv1aPrime = 16777619u;
|
||||
|
||||
static constexpr uint16_t toBe565(uint16_t color)
|
||||
{
|
||||
return static_cast<uint16_t>((color >> 8) | (color << 8));
|
||||
}
|
||||
|
||||
static constexpr bool kRoleIsBody[static_cast<size_t>(TFTColorRole::Count)] = {
|
||||
false, // HeaderBackground
|
||||
false, // HeaderTitle
|
||||
false, // HeaderStatus
|
||||
true, // SignalBars
|
||||
true, // ConnectionIcon
|
||||
true, // UtilizationFill
|
||||
true, // FavoriteNode
|
||||
true, // ActionMenuBorder
|
||||
true, // ActionMenuBody
|
||||
true, // ActionMenuTitle
|
||||
true, // FrameMono
|
||||
false, // BootSplash
|
||||
true, // FavoriteNodeBGHighlight
|
||||
false, // NavigationBar
|
||||
false // NavigationArrow
|
||||
};
|
||||
|
||||
static inline bool isBodyColorRole(TFTColorRole role)
|
||||
{
|
||||
return kRoleIsBody[static_cast<size_t>(role)];
|
||||
}
|
||||
|
||||
static inline bool isMonochromeTheme(uint32_t themeId)
|
||||
{
|
||||
return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
|
||||
}
|
||||
|
||||
static inline uint16_t getMonochromeAccent(uint32_t themeId)
|
||||
{
|
||||
return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen
|
||||
: (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed
|
||||
: TFTPalette::White;
|
||||
}
|
||||
|
||||
static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to)
|
||||
{
|
||||
if (value == from) {
|
||||
value = to;
|
||||
}
|
||||
}
|
||||
|
||||
static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value)
|
||||
{
|
||||
return (hash ^ value) * kFnv1aPrime;
|
||||
}
|
||||
|
||||
static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value)
|
||||
{
|
||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>(value & 0xFF));
|
||||
hash = fnv1aAppendByte(hash, static_cast<uint8_t>((value >> 8) & 0xFF));
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Compile-time header color overrides (backward-compatible)
|
||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kTitleColor = TFTPalette::White;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
||||
static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
||||
#else
|
||||
static constexpr uint16_t kStatusColor = TFTPalette::White;
|
||||
#endif
|
||||
|
||||
// Theme definitions
|
||||
// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color
|
||||
// against each entry's .uniqueIdentifier field.
|
||||
|
||||
static const TFTThemeDef kThemes[] = {
|
||||
|
||||
// Default Dark (ThemeID::DefaultDark = 0)
|
||||
{
|
||||
ThemeID::DefaultDark, // id
|
||||
"Default Dark", // name
|
||||
0, // uniqueIdentifier
|
||||
// roles[TFTColorRole::Count]
|
||||
{
|
||||
{kHeaderBackground, TFTPalette::Black}, // HeaderBackground
|
||||
{kHeaderBackground, kTitleColor}, // HeaderTitle
|
||||
{kHeaderBackground, kStatusColor}, // HeaderStatus
|
||||
{TFTPalette::Good, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::Good, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg)
|
||||
{kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::Good, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
false, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Default Light (ThemeID::DefaultLight = 1)
|
||||
{
|
||||
ThemeID::DefaultLight, // id
|
||||
"Default Light", // name
|
||||
1, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::Good, TFTPalette::White}, // SignalBars
|
||||
{TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon
|
||||
{TFTPalette::Good, TFTPalette::White}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode
|
||||
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::White}, // ActionMenuBody
|
||||
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::Good, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Christmas (ThemeID::Christmas = 2)
|
||||
{
|
||||
ThemeID::Christmas, // id
|
||||
"Christmas", // name
|
||||
2, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus
|
||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon
|
||||
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode
|
||||
{TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody
|
||||
{TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Pine, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::ChristmasGreen, // batteryFillGood
|
||||
TFTPalette::Gold, // batteryFillMedium
|
||||
TFTPalette::ChristmasRed, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
false, // visible
|
||||
},
|
||||
|
||||
// Pink (ThemeID::Pink = 3) light variant
|
||||
{
|
||||
ThemeID::Pink, // id
|
||||
"Pink", // name
|
||||
3, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus
|
||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon
|
||||
{TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody
|
||||
{TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::HotPink}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::DeepPink, // batteryFillGood
|
||||
TFTPalette::HotPink, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Blue (ThemeID::Blue = 4) dark variant
|
||||
{
|
||||
ThemeID::Blue, // id
|
||||
"Blue", // name
|
||||
4, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode
|
||||
{TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody
|
||||
{TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Navy, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash
|
||||
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::SkyBlue, // batteryFillGood
|
||||
TFTPalette::Medium, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Creamsicle (ThemeID::Creamsicle = 5)light variant
|
||||
{
|
||||
ThemeID::Creamsicle, // id
|
||||
"Creamsicle", // name
|
||||
5, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus
|
||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars
|
||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon
|
||||
{TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill
|
||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode
|
||||
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder
|
||||
{TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono
|
||||
{TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash
|
||||
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg)
|
||||
{TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
|
||||
},
|
||||
TFTPalette::DeepOrange, // batteryFillGood
|
||||
TFTPalette::Gold, // batteryFillMedium
|
||||
TFTPalette::Bad, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome
|
||||
// Pure single-color-on-black look. Every role maps foreground pixels to
|
||||
// the theme color and background pixels to Black.
|
||||
{
|
||||
ThemeID::MeshtasticGreen, // id
|
||||
"Meshtastic Green", // name
|
||||
6, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Classic Red (ThemeID::ClassicRed = 7) classic monochrome
|
||||
{
|
||||
ThemeID::ClassicRed, // id
|
||||
"Classic Red", // name
|
||||
7, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
|
||||
// Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome
|
||||
{
|
||||
ThemeID::MonochromeWhite, // id
|
||||
"Monochrome White", // name
|
||||
8, // uniqueIdentifier
|
||||
{
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderBackground
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderTitle
|
||||
{TFTPalette::White, TFTPalette::Black}, // HeaderStatus
|
||||
{TFTPalette::White, TFTPalette::Black}, // SignalBars
|
||||
{TFTPalette::White, TFTPalette::Black}, // ConnectionIcon
|
||||
{TFTPalette::White, TFTPalette::Black}, // UtilizationFill
|
||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNode
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
|
||||
{TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle
|
||||
{TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg)
|
||||
{TFTPalette::White, TFTPalette::Black}, // BootSplash
|
||||
{TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight
|
||||
{TFTPalette::White, TFTPalette::Black}, // NavigationBar
|
||||
{TFTPalette::White, TFTPalette::Black}, // NavigationArrow
|
||||
},
|
||||
TFTPalette::Black, // batteryFillGood
|
||||
TFTPalette::Black, // batteryFillMedium
|
||||
TFTPalette::Black, // batteryFillBad
|
||||
true, // fullFrameInvert
|
||||
true, // visible
|
||||
},
|
||||
};
|
||||
|
||||
static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]);
|
||||
|
||||
// Resolve the kThemes[] index for the currently persisted theme. Called at
|
||||
// boot (indirectly via getActiveTheme()) and whenever the active theme is
|
||||
// queried, so uiconfig.screen_rgb_color remains the single source of truth.
|
||||
// Matches against .uniqueIdentifier - that's the field whose value is stored
|
||||
// in the user's config. Falls back to 0 (DefaultDark) if no match is found,
|
||||
// which gracefully handles removed or retired themes.
|
||||
static inline size_t resolveThemeIndex()
|
||||
{
|
||||
const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (kThemes[i].uniqueIdentifier == savedIdentifier)
|
||||
return i;
|
||||
}
|
||||
return 0; // Default Dark fallback
|
||||
}
|
||||
|
||||
static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (x < 0) {
|
||||
width += x;
|
||||
x = 0;
|
||||
}
|
||||
if (y < 0) {
|
||||
height += y;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
|
||||
static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe)
|
||||
{
|
||||
// Keep the last slot permanently disabled as a sentinel for ST7789 scans.
|
||||
// This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries.
|
||||
if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) {
|
||||
memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2));
|
||||
colorRegionCount = MAX_TFT_COLOR_REGIONS - 2;
|
||||
}
|
||||
|
||||
TFTColorRegion ®ion = colorRegions[colorRegionCount++];
|
||||
region.x = x;
|
||||
region.y = y;
|
||||
region.width = width;
|
||||
region.height = height;
|
||||
region.onColorBe = onColorBe;
|
||||
region.offColorBe = offColorBe;
|
||||
region.enabled = true;
|
||||
|
||||
// Keep one disabled sentinel after the active range for ST7789 countColorRegions().
|
||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
||||
colorRegions[colorRegionCount].enabled = false;
|
||||
}
|
||||
colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false;
|
||||
}
|
||||
|
||||
// Current working role colors (big-endian). Initialised to Dark defaults;
|
||||
// call loadThemeDefaults() after boot / theme change to refresh.
|
||||
static TFTRoleColorsBe roleColors[static_cast<size_t>(TFTColorRole::Count)] = {
|
||||
{toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground
|
||||
{toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle
|
||||
{toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus
|
||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars
|
||||
{toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon
|
||||
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill
|
||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode
|
||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder
|
||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody
|
||||
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle
|
||||
{toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono
|
||||
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash
|
||||
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight
|
||||
{toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar
|
||||
{toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Theme accessors
|
||||
|
||||
const TFTThemeDef &getActiveTheme()
|
||||
{
|
||||
return kThemes[resolveThemeIndex()];
|
||||
}
|
||||
|
||||
// Visible-theme accessors
|
||||
// These iterate only themes flagged .visible = true, preserving kThemes[]
|
||||
// order. Menu code should use these so hidden themes don't appear in the
|
||||
// picker while still applying correctly if their ID is persisted.
|
||||
|
||||
size_t getVisibleThemeCount()
|
||||
{
|
||||
size_t count = 0;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (kThemes[i].visible)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex)
|
||||
{
|
||||
size_t seen = 0;
|
||||
for (size_t i = 0; i < kInternalThemeCount; i++) {
|
||||
if (!kThemes[i].visible)
|
||||
continue;
|
||||
if (seen == visibleIndex)
|
||||
return kThemes[i];
|
||||
seen++;
|
||||
}
|
||||
// Fallback: return first theme (never trust a bad index).
|
||||
return kThemes[0];
|
||||
}
|
||||
|
||||
size_t getActiveVisibleThemeIndex()
|
||||
{
|
||||
const size_t active = resolveThemeIndex();
|
||||
if (!kThemes[active].visible)
|
||||
return SIZE_MAX;
|
||||
size_t visibleIdx = 0;
|
||||
for (size_t i = 0; i < active; i++) {
|
||||
if (kThemes[i].visible)
|
||||
visibleIdx++;
|
||||
}
|
||||
return visibleIdx;
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderBg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
|
||||
return TFT_HEADER_BG_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderBackground)].onColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::DarkGray;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderText()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
|
||||
return TFT_HEADER_TITLE_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderTitle)].offColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeHeaderStatus()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
|
||||
return TFT_HEADER_STATUS_COLOR_OVERRIDE;
|
||||
#else
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderStatus)].offColor;
|
||||
#endif
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBodyBg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].onColor;
|
||||
#else
|
||||
return TFTPalette::Black;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBodyFg()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].offColor;
|
||||
#else
|
||||
return TFTPalette::White;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isThemeFullFrameInvert()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
return kThemes[resolveThemeIndex()].fullFrameInvert;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t getThemeBatteryFillColor(int batteryPercent)
|
||||
{
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
if (batteryPercent <= 20) {
|
||||
return theme.batteryFillBad;
|
||||
}
|
||||
if (batteryPercent <= 50) {
|
||||
return theme.batteryFillMedium;
|
||||
}
|
||||
return theme.batteryFillGood;
|
||||
}
|
||||
|
||||
void loadThemeDefaults()
|
||||
{
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
for (uint8_t i = 0; i < static_cast<uint8_t>(TFTColorRole::Count); i++) {
|
||||
roleColors[i].onColorBe = toBe565(theme.roles[i].onColor);
|
||||
roleColors[i].offColorBe = toBe565(theme.roles[i].offColor);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Role color assignment with theme-aware transforms
|
||||
|
||||
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
const uint8_t index = static_cast<uint8_t>(role);
|
||||
if (index >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F;
|
||||
const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight);
|
||||
const bool isBodyRole = !isHighlightRole && isBodyColorRole(role);
|
||||
|
||||
// Classic monochrome themes collapse all non-black accents into one tone.
|
||||
if (isMonochromeTheme(themeId)) {
|
||||
if (onColor != TFTPalette::Black) {
|
||||
onColor = getMonochromeAccent(themeId);
|
||||
}
|
||||
} else {
|
||||
switch (themeId) {
|
||||
case ThemeID::DefaultLight:
|
||||
if (isHighlightRole) {
|
||||
// High-contrast highlight chips on light UI.
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::Yellow;
|
||||
} else if (isBodyRole) {
|
||||
// Invert body colors for readability on white frames.
|
||||
if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) {
|
||||
offColor = TFTPalette::White;
|
||||
}
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Christmas:
|
||||
if (isHighlightRole || isBodyRole) {
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold);
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Pink:
|
||||
if (isHighlightRole) {
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::HotPink;
|
||||
} else if (isBodyRole) {
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink);
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Creamsicle:
|
||||
if (isHighlightRole) {
|
||||
onColor = TFTPalette::Black;
|
||||
offColor = TFTPalette::CreamOrange;
|
||||
} else if (isBodyRole) {
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream);
|
||||
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange);
|
||||
}
|
||||
break;
|
||||
case ThemeID::Blue:
|
||||
if (isHighlightRole || isBodyRole) {
|
||||
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue);
|
||||
replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
roleColors[index].onColorBe = toBe565(onColor);
|
||||
roleColors[index].offColorBe = toBe565(offColor);
|
||||
}
|
||||
|
||||
// Region registration
|
||||
|
||||
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
const uint8_t roleIndex = static_cast<uint8_t>(role);
|
||||
if (roleIndex >= static_cast<uint8_t>(TFTColorRole::Count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizeRegion(x, y, width, height)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const TFTRoleColorsBe &colors = roleColors[roleIndex];
|
||||
appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe);
|
||||
}
|
||||
|
||||
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
|
||||
int16_t height)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)role;
|
||||
(void)onColor;
|
||||
(void)offColor;
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)width;
|
||||
(void)height;
|
||||
return;
|
||||
#else
|
||||
setTFTColorRole(role, onColor, offColor);
|
||||
registerTFTColorRegion(role, x, y, width, height);
|
||||
#endif
|
||||
}
|
||||
|
||||
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return;
|
||||
#endif
|
||||
|
||||
if (!normalizeRegion(x, y, width, height))
|
||||
return;
|
||||
|
||||
appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor));
|
||||
}
|
||||
|
||||
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)boxLeft;
|
||||
(void)boxTop;
|
||||
(void)boxWidth;
|
||||
(void)boxHeight;
|
||||
return;
|
||||
#else
|
||||
// Use theme-appropriate menu colors.
|
||||
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
|
||||
const TFTThemeRoleColor &menuBody = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBody)];
|
||||
const TFTThemeRoleColor &menuBorder = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBorder)];
|
||||
|
||||
// Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly.
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1,
|
||||
boxWidth + 2, boxHeight + 2);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight);
|
||||
|
||||
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth,
|
||||
1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight);
|
||||
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Frame signature & utilities
|
||||
|
||||
uint32_t getTFTColorFrameSignature()
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return 0;
|
||||
#else
|
||||
uint32_t hash = kFnv1aOffsetBasis;
|
||||
hash = fnv1aAppendByte(hash, colorRegionCount);
|
||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
||||
const TFTColorRegion &r = colorRegions[i];
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.x));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.y));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.width));
|
||||
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.height));
|
||||
hash = fnv1aAppendU16(hash, r.onColorBe);
|
||||
hash = fnv1aAppendU16(hash, r.offColorBe);
|
||||
}
|
||||
|
||||
return hash;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint8_t getTFTColorRegionCount()
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
return 0;
|
||||
#else
|
||||
return colorRegionCount;
|
||||
#endif
|
||||
}
|
||||
|
||||
void clearTFTColorRegions()
|
||||
{
|
||||
for (uint8_t i = 0; i < colorRegionCount; i++) {
|
||||
colorRegions[i].enabled = false;
|
||||
}
|
||||
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
|
||||
colorRegions[colorRegionCount].enabled = false;
|
||||
}
|
||||
colorRegionCount = 0;
|
||||
}
|
||||
|
||||
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
|
||||
{
|
||||
for (int i = static_cast<int>(colorRegionCount) - 1; i >= 0; i--) {
|
||||
const TFTColorRegion &r = colorRegions[i];
|
||||
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
|
||||
return isset ? r.onColorBe : r.offColorBe;
|
||||
}
|
||||
}
|
||||
return isset ? defaultOnColor : defaultOffColor;
|
||||
}
|
||||
|
||||
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor)
|
||||
{
|
||||
#if !GRAPHICS_TFT_COLORING_ENABLED
|
||||
(void)x;
|
||||
(void)y;
|
||||
return defaultOffColor;
|
||||
#else
|
||||
const uint16_t defaultOffBe = toBe565(defaultOffColor);
|
||||
const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe);
|
||||
return static_cast<uint16_t>((sampledBe >> 8) | (sampledBe << 8));
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
@@ -1,163 +0,0 @@
|
||||
#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
|
||||
+42
-160
@@ -16,6 +16,12 @@
|
||||
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
|
||||
|
||||
@@ -422,7 +428,7 @@ static LGFX *tft = nullptr;
|
||||
|
||||
#elif defined(ST7789_CS)
|
||||
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
|
||||
#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)
|
||||
#ifdef HELTEC_V4_TFT
|
||||
#include "chsc6x.h"
|
||||
#include "lgfx/v1/Touch.hpp"
|
||||
namespace lgfx
|
||||
@@ -444,11 +450,7 @@ class TOUCH_CHSC6X : public ITouch
|
||||
bool init(void) override
|
||||
{
|
||||
if (chsc6xTouch == nullptr) {
|
||||
#if (TOUCH_I2C_PORT == 1)
|
||||
chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN);
|
||||
#else
|
||||
chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN);
|
||||
#endif
|
||||
}
|
||||
chsc6xTouch->chsc6x_init();
|
||||
return true;
|
||||
@@ -485,7 +487,7 @@ class LGFX : public lgfx::LGFX_Device
|
||||
#if HAS_TOUCHSCREEN
|
||||
#if defined(T_WATCH_S3) || defined(ELECROW)
|
||||
lgfx::Touch_FT5x06 _touch_instance;
|
||||
#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)
|
||||
#elif defined(HELTEC_V4_TFT)
|
||||
lgfx::TOUCH_CHSC6X _touch_instance;
|
||||
#else
|
||||
lgfx::Touch_GT911 _touch_instance;
|
||||
@@ -504,11 +506,7 @@ class LGFX : public lgfx::LGFX_Device
|
||||
cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing
|
||||
// 80MHz by an integer)
|
||||
cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving
|
||||
#ifdef SPI_3_WIRE
|
||||
cfg.spi_3wire = SPI_3_WIRE;
|
||||
#else
|
||||
cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin
|
||||
#endif
|
||||
cfg.spi_3wire = false;
|
||||
cfg.use_lock = true; // Set to true to use transaction locking
|
||||
cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch /
|
||||
// SPI_DMA_CH_AUTO=auto setting)
|
||||
@@ -558,11 +556,8 @@ class LGFX : public lgfx::LGFX_Device
|
||||
cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped
|
||||
cfg.dlen_16bit =
|
||||
false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI
|
||||
#if defined(HAS_SDCARD)
|
||||
cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.)
|
||||
#else
|
||||
cfg.bus_shared = false;
|
||||
#endif
|
||||
|
||||
// Set the following only when the display is shifted with a driver with a variable number of pixels, such as the
|
||||
// ST7735 or ILI9163.
|
||||
// cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC
|
||||
@@ -1145,9 +1140,7 @@ static LGFX *tft = nullptr;
|
||||
#endif
|
||||
|
||||
#include "SPILock.h"
|
||||
#include "TFTColorRegions.h"
|
||||
#include "TFTDisplay.h"
|
||||
#include "TFTPalette.h"
|
||||
#include <SPI.h>
|
||||
|
||||
#ifdef UNPHONE
|
||||
@@ -1157,25 +1150,6 @@ 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!");
|
||||
@@ -1215,15 +1189,14 @@ 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;
|
||||
@@ -1232,70 +1205,12 @@ void TFTDisplay::display(bool fromBlank)
|
||||
uint32_t x_FirstPixelUpdate;
|
||||
uint32_t x_LastPixelUpdate;
|
||||
bool isset, dblbuf_isset;
|
||||
uint16_t colorTftWhite, colorTftBlack;
|
||||
uint16_t colorTftMesh, colorTftBlack;
|
||||
bool somethingChanged = false;
|
||||
|
||||
// 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
|
||||
// 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);
|
||||
|
||||
y = 0;
|
||||
while (y < displayHeight) {
|
||||
@@ -1304,7 +1219,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 (!forceFullRepaint) {
|
||||
if (!fromBlank) {
|
||||
for (x = 0; x < displayWidth; x++) {
|
||||
if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex])
|
||||
break;
|
||||
@@ -1322,14 +1237,13 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
if (!forceFullRepaint) {
|
||||
if (!fromBlank) {
|
||||
// get src pixel in the page based ordering the OLED lib uses
|
||||
dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask;
|
||||
dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
|
||||
if (isset != dblbuf_isset) {
|
||||
break;
|
||||
}
|
||||
@@ -1339,42 +1253,26 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
|
||||
// Did we find a pixel that needs updating on this row?
|
||||
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 (x_FirstPixelUpdate < displayWidth) {
|
||||
|
||||
// Quickly write out the first changed pixel (saves another array lookup)
|
||||
linePixelBuffer[x_FirstPixelUpdate] = isset ? colorTftMesh : colorTftBlack;
|
||||
x_LastPixelUpdate = x_FirstPixelUpdate;
|
||||
|
||||
// Step 3: copy all remaining pixels in this row into the pixel line buffer,
|
||||
// while also recording the last pixel in the row that needs updating
|
||||
for (x = x_FirstPixelUpdate + 1; x < displayWidth; x++) {
|
||||
isset = buffer[x + y_byteIndex] & y_byteMask;
|
||||
linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack;
|
||||
|
||||
if (!fromBlank) {
|
||||
dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask;
|
||||
if (isset != dblbuf_isset) {
|
||||
break;
|
||||
x_LastPixelUpdate = x;
|
||||
}
|
||||
} else if (isset) {
|
||||
break;
|
||||
x_LastPixelUpdate = x;
|
||||
}
|
||||
x_LastChanged--;
|
||||
}
|
||||
|
||||
// Align the first pixel for update to an even number so the total alignment of
|
||||
// the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
|
||||
x_FirstPixelUpdate = x_FirstChanged & ~1U;
|
||||
x_LastPixelUpdate = x_LastChanged | 1U;
|
||||
if (x_LastPixelUpdate >= displayWidth) {
|
||||
x_LastPixelUpdate = displayWidth - 1;
|
||||
}
|
||||
|
||||
// Step 3: Copy only the changed span into the pixel line buffer.
|
||||
for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
|
||||
isset = buffer[x + y_byteIndex] & y_byteMask;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (hasColorRegions) {
|
||||
linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
|
||||
colorTftWhite, colorTftBlack);
|
||||
} else {
|
||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
}
|
||||
#else
|
||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
#endif
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
|
||||
@@ -1382,8 +1280,8 @@ void TFTDisplay::display(bool fromBlank)
|
||||
#else
|
||||
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
|
||||
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
|
||||
tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||
tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||
#endif
|
||||
somethingChanged = true;
|
||||
}
|
||||
@@ -1392,14 +1290,6 @@ 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()
|
||||
@@ -1613,7 +1503,7 @@ bool TFTDisplay::connect()
|
||||
#else
|
||||
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
|
||||
#endif
|
||||
tft->fillScreen(getThemeDefaultOffColor());
|
||||
tft->fillScreen(TFT_BLACK);
|
||||
|
||||
if (this->linePixelBuffer == NULL) {
|
||||
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
|
||||
@@ -1623,14 +1513,6 @@ bool TFTDisplay::connect()
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this->repaintChunkBuffer == NULL) {
|
||||
this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows);
|
||||
|
||||
if (!this->repaintChunkBuffer) {
|
||||
LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,5 +63,4 @@ class TFTDisplay : public OLEDDisplay
|
||||
virtual bool connect() override;
|
||||
|
||||
uint16_t *linePixelBuffer = nullptr;
|
||||
uint16_t *repaintChunkBuffer = nullptr;
|
||||
};
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
#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
|
||||
@@ -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, true);
|
||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
|
||||
|
||||
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
|
||||
char timeString[16];
|
||||
@@ -293,15 +293,11 @@ 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, true);
|
||||
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
|
||||
|
||||
// clock face center coordinates
|
||||
int16_t centerX = display->getWidth() / 2;
|
||||
@@ -482,4 +478,4 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
} // namespace ClockRenderer
|
||||
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,6 +1,10 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#include "CompassRenderer.h"
|
||||
#include "NodeDB.h"
|
||||
#include "UIRenderer.h"
|
||||
#include "configuration.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include <cmath>
|
||||
@@ -9,103 +13,111 @@ namespace graphics
|
||||
{
|
||||
namespace CompassRenderer
|
||||
{
|
||||
|
||||
// Point helper class for compass calculations
|
||||
struct Point {
|
||||
float x, y;
|
||||
Point(float x, float y) : x(x), y(y) {}
|
||||
|
||||
void rotate(float angle)
|
||||
{
|
||||
float cos_a = cos(angle);
|
||||
float sin_a = sin(angle);
|
||||
float new_x = x * cos_a - y * sin_a;
|
||||
float new_y = x * sin_a + y * cos_a;
|
||||
x = new_x;
|
||||
y = new_y;
|
||||
}
|
||||
|
||||
void scale(float factor)
|
||||
{
|
||||
x *= factor;
|
||||
y *= factor;
|
||||
}
|
||||
|
||||
void translate(float dx, float dy)
|
||||
{
|
||||
x += dx;
|
||||
y += dy;
|
||||
}
|
||||
};
|
||||
|
||||
void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
|
||||
{
|
||||
// Show the compass heading (not implemented in original)
|
||||
// This could draw a "N" indicator or north arrow
|
||||
// For now, we'll draw a simple north indicator
|
||||
// const float radius = 17.0f;
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
radius += 4;
|
||||
}
|
||||
|
||||
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));
|
||||
Point north(0, -radius);
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
north.rotate(-myHeading);
|
||||
north.translate(compassX, compassY);
|
||||
|
||||
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(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
|
||||
display->fillRect(north.x - 8, north.y - 1, display->getStringWidth("N") + 3, FONT_HEIGHT_SMALL - 6);
|
||||
} else {
|
||||
display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
|
||||
display->fillRect(north.x - 4, north.y - 1, display->getStringWidth("N") + 2, FONT_HEIGHT_SMALL - 6);
|
||||
}
|
||||
#endif
|
||||
display->setColor(WHITE);
|
||||
display->drawString(nX, nY - 3, "N");
|
||||
}
|
||||
|
||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
|
||||
{
|
||||
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;
|
||||
|
||||
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));
|
||||
|
||||
display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY);
|
||||
display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY);
|
||||
display->drawString(north.x, north.y - 3, "N");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
bool getHeadingRadians(double lat, double lon, float &headingRadian)
|
||||
{
|
||||
headingRadian = 0.0f;
|
||||
Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
|
||||
|
||||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING)
|
||||
return true;
|
||||
|
||||
if (!screen)
|
||||
return false;
|
||||
|
||||
if (screen->hasHeading()) {
|
||||
headingRadian = screen->getHeading() * DEG_TO_RAD;
|
||||
return true;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
arrowPoints[i]->rotate(headingRadian);
|
||||
arrowPoints[i]->scale(compassDiam * 0.6);
|
||||
arrowPoints[i]->translate(compassX, compassY);
|
||||
}
|
||||
|
||||
const float estimatedHeadingDeg = screen->estimatedHeading(lat, lon);
|
||||
if (!(estimatedHeadingDeg >= 0.0f))
|
||||
return false;
|
||||
|
||||
headingRadian = estimatedHeadingDeg * DEG_TO_RAD;
|
||||
return true;
|
||||
#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);
|
||||
}
|
||||
|
||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian)
|
||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
|
||||
{
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||||
return bearingRadian - headingRadian;
|
||||
float radians = bearing * DEG_TO_RAD;
|
||||
|
||||
return bearingRadian;
|
||||
Point tip(0, -size / 2);
|
||||
Point left(-size / 6, size / 4);
|
||||
Point right(size / 6, size / 4);
|
||||
Point tail(0, size / 4.5);
|
||||
|
||||
tip.rotate(radians);
|
||||
left.rotate(radians);
|
||||
right.rotate(radians);
|
||||
tail.rotate(radians);
|
||||
|
||||
tip.translate(x, y);
|
||||
left.translate(x, y);
|
||||
right.translate(x, y);
|
||||
tail.translate(x, y);
|
||||
|
||||
display->fillTriangle(tip.x, tip.y, left.x, left.y, tail.x, tail.y);
|
||||
display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y);
|
||||
}
|
||||
|
||||
float radiansToDegrees360(float angleRadian)
|
||||
float estimatedHeading(double lat, double lon)
|
||||
{
|
||||
constexpr float fullTurnDeg = 360.0f;
|
||||
float degrees = angleRadian * RAD_TO_DEG;
|
||||
if (degrees < 0.0f)
|
||||
degrees += fullTurnDeg;
|
||||
else if (degrees >= fullTurnDeg)
|
||||
degrees -= fullTurnDeg;
|
||||
return degrees;
|
||||
// Simple magnetic declination estimation
|
||||
// This is a very basic implementation - the original might be more sophisticated
|
||||
return 0.0f; // Return 0 for now, indicating no heading available
|
||||
}
|
||||
|
||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
||||
@@ -125,4 +137,4 @@ uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
|
||||
|
||||
} // namespace CompassRenderer
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
@@ -25,9 +25,7 @@ void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, u
|
||||
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing);
|
||||
|
||||
// Navigation and location functions
|
||||
bool getHeadingRadians(double lat, double lon, float &headingRadian);
|
||||
float adjustBearingForCompassMode(float bearingRadian, float headingRadian);
|
||||
float radiansToDegrees360(float angleRadian);
|
||||
float estimatedHeading(double lat, double lon);
|
||||
uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
|
||||
|
||||
} // namespace CompassRenderer
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
#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"
|
||||
@@ -410,16 +408,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
display->drawString(nameX, getTextPositions(display)[line++], device_role);
|
||||
|
||||
// === Third Row: Radio Preset ===
|
||||
// For custom modem settings show the actual parameters; for presets use the preset name.
|
||||
char modeStr[16];
|
||||
if (!config.lora.use_preset) {
|
||||
snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast<unsigned>(config.lora.bandwidth),
|
||||
static_cast<unsigned>(config.lora.spread_factor), static_cast<unsigned>(config.lora.coding_rate));
|
||||
} else {
|
||||
strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true),
|
||||
sizeof(modeStr) - 1);
|
||||
modeStr[sizeof(modeStr) - 1] = '\0';
|
||||
}
|
||||
auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
|
||||
|
||||
char regionradiopreset[25];
|
||||
const char *region = myRegion ? myRegion->name : NULL;
|
||||
@@ -427,7 +416,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region);
|
||||
} else {
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr);
|
||||
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode);
|
||||
}
|
||||
}
|
||||
textWidth = display->getStringWidth(regionradiopreset);
|
||||
@@ -471,11 +460,9 @@ 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;
|
||||
@@ -483,7 +470,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
|
||||
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
||||
|
||||
// Force 61% or higher to show a full 100% bar, text would still show related percent.
|
||||
// Force 56% or higher to show a full 100% bar, text would still show related percent.
|
||||
if (chutil_percent >= 61) {
|
||||
chutil_percent = 100;
|
||||
}
|
||||
@@ -496,9 +483,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_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 seg1 = chutil_bar_width * (weight1 / totalWeight);
|
||||
int seg2 = chutil_bar_width * (weight2 / totalWeight);
|
||||
int seg3 = chutil_bar_width * (weight3 / totalWeight);
|
||||
|
||||
int fillRight = 0;
|
||||
|
||||
@@ -515,17 +502,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
|
||||
// Fill progress
|
||||
if (fillRight > 0) {
|
||||
#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->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
|
||||
}
|
||||
|
||||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++],
|
||||
@@ -598,17 +575,6 @@ 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
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#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"
|
||||
@@ -19,7 +18,6 @@
|
||||
#include "main.h"
|
||||
#include "mesh/Default.h"
|
||||
#include "mesh/MeshTypes.h"
|
||||
#include "mesh/RadioLibInterface.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
@@ -27,10 +25,11 @@
|
||||
#include "modules/TraceRouteModule.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
|
||||
extern uint16_t TFT_MESH;
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
|
||||
@@ -160,22 +159,31 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: without a reboot, reconfigure() applies the region directly.
|
||||
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
|
||||
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora
|
||||
// rather than a hardcoded check for LORA_24.
|
||||
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
|
||||
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
|
||||
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection");
|
||||
return;
|
||||
}
|
||||
|
||||
config.lora.region = selectedRegion;
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
// FIXME: This should be a method consolidated with the same logic in the admin message as well
|
||||
// This is needed as we wait til picking the LoRa region to generate keys for the first time.
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
if (!owner.is_licensed) {
|
||||
bool keygenSuccess = false;
|
||||
if (config.security.private_key.size == 32) {
|
||||
// public key is derived from private, so this will always have the same result.
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
} else {
|
||||
LOG_INFO("Generate new PKI keys");
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
config.lora.tx_enabled = true;
|
||||
@@ -191,6 +199,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
}
|
||||
|
||||
service->reloadConfig(changes);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
});
|
||||
|
||||
bannerOptions.durationMs = duration;
|
||||
@@ -256,24 +265,13 @@ void menuHandler::FrequencySlotPicker()
|
||||
optionsEnumArray[options++] = 0;
|
||||
|
||||
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
|
||||
|
||||
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
|
||||
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
|
||||
: bwCodeToKHz(loraConfig.bandwidth);
|
||||
|
||||
uint32_t numChannels = 0;
|
||||
if (myRegion) {
|
||||
// Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round()
|
||||
const double spacing = myRegion->profile->spacing;
|
||||
const double padding = myRegion->profile->padding;
|
||||
const double channelBandwidthMHz = bw / 1000.0;
|
||||
const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing;
|
||||
const double denominator = spacing + (padding * 2) + channelBandwidthMHz;
|
||||
if (denominator > 0.0) {
|
||||
numChannels = static_cast<uint32_t>(round(numerator / denominator));
|
||||
} else {
|
||||
LOG_WARN("Invalid region configuration: non-positive channel spacing/width");
|
||||
}
|
||||
numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0)));
|
||||
} else {
|
||||
LOG_WARN("Region not set, cannot calculate number of channels");
|
||||
return;
|
||||
@@ -309,6 +307,7 @@ void menuHandler::FrequencySlotPicker()
|
||||
|
||||
config.lora.channel_num = selected;
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
};
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
@@ -347,6 +346,7 @@ void menuHandler::radioPresetPicker()
|
||||
config.lora.channel_num = 0; // Reset to default channel for the preset
|
||||
config.lora.override_frequency = 0; // Clear any custom frequency
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
||||
});
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
@@ -2027,6 +2027,109 @@ 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"};
|
||||
@@ -2214,9 +2317,9 @@ void menuHandler::screenOptionsMenu()
|
||||
bool hasSupportBrightness = false;
|
||||
#endif
|
||||
|
||||
enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme };
|
||||
static const char *optionsArray[7] = {"Back"};
|
||||
static int optionsEnumArray[7] = {Back};
|
||||
enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles };
|
||||
static const char *optionsArray[6] = {"Back"};
|
||||
static int optionsEnumArray[6] = {Back};
|
||||
int options = 1;
|
||||
|
||||
// Only show brightness for B&W displays
|
||||
@@ -2225,6 +2328,13 @@ 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;
|
||||
|
||||
@@ -2234,11 +2344,6 @@ 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;
|
||||
@@ -2248,6 +2353,9 @@ 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();
|
||||
@@ -2257,9 +2365,6 @@ 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();
|
||||
@@ -2543,53 +2648,6 @@ 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)
|
||||
@@ -2665,6 +2723,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case MuiPicker:
|
||||
switchToMUIMenu();
|
||||
break;
|
||||
case TftColorMenuPicker:
|
||||
TFTColorPickerMenu(display);
|
||||
break;
|
||||
case BrightnessPicker:
|
||||
BrightnessPickerMenu();
|
||||
break;
|
||||
@@ -2737,9 +2798,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case MessageBubblesMenu:
|
||||
messageBubblesMenu();
|
||||
break;
|
||||
case ThemeMenu:
|
||||
themeMenu();
|
||||
break;
|
||||
}
|
||||
menuQueue = MenuNone;
|
||||
}
|
||||
@@ -2751,4 +2809,4 @@ void menuHandler::saveUIConfig()
|
||||
|
||||
} // namespace graphics
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -30,6 +30,7 @@ class menuHandler
|
||||
ResetNodeDbMenu,
|
||||
BuzzerModeMenuPicker,
|
||||
MuiPicker,
|
||||
TftColorMenuPicker,
|
||||
BrightnessPicker,
|
||||
RebootMenu,
|
||||
ShutdownMenu,
|
||||
@@ -54,8 +55,7 @@ class menuHandler
|
||||
NodeNameLengthMenu,
|
||||
FrameToggles,
|
||||
DisplayUnits,
|
||||
MessageBubblesMenu,
|
||||
ThemeMenu
|
||||
MessageBubblesMenu
|
||||
};
|
||||
static screenMenus menuQueue;
|
||||
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
|
||||
@@ -89,6 +89,7 @@ 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();
|
||||
@@ -109,7 +110,6 @@ class menuHandler
|
||||
static void frameTogglesMenu();
|
||||
static void displayUnitsMenu();
|
||||
static void messageBubblesMenu();
|
||||
static void themeMenu();
|
||||
static void textMessageMenu();
|
||||
|
||||
private:
|
||||
@@ -136,10 +136,23 @@ template <typename T> struct MenuOption {
|
||||
MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {}
|
||||
};
|
||||
|
||||
struct ScreenColor {
|
||||
uint8_t r;
|
||||
uint8_t g;
|
||||
uint8_t b;
|
||||
bool useVariant;
|
||||
|
||||
explicit ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false)
|
||||
: r(rIn), g(gIn), b(bIn), useVariant(variantIn)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
using RadioPresetOption = MenuOption<meshtastic_Config_LoRaConfig_ModemPreset>;
|
||||
using LoraRegionOption = MenuOption<meshtastic_Config_LoRaConfig_RegionCode>;
|
||||
using TimezoneOption = MenuOption<const char *>;
|
||||
using CompassOption = MenuOption<meshtastic_CompassMode>;
|
||||
using ScreenColorOption = MenuOption<ScreenColor>;
|
||||
using GPSToggleOption = MenuOption<meshtastic_Config_PositionConfig_GpsMode>;
|
||||
using GPSFormatOption = MenuOption<meshtastic_DeviceUIConfig_GpsCoordinateFormat>;
|
||||
using NodeNameOption = MenuOption<bool>;
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
#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"
|
||||
@@ -256,76 +254,6 @@ 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) {
|
||||
@@ -720,11 +648,6 @@ 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());
|
||||
@@ -763,17 +686,6 @@ 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];
|
||||
@@ -823,56 +735,24 @@ 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
|
||||
|
||||
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 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
|
||||
#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);
|
||||
}
|
||||
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;
|
||||
@@ -892,7 +772,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,
|
||||
true);
|
||||
false);
|
||||
|
||||
// Draw underline just under header text
|
||||
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
||||
|
||||
@@ -3,16 +3,11 @@
|
||||
#include "CompassRenderer.h"
|
||||
#include "NodeDB.h"
|
||||
#include "NodeListRenderer.h"
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
#include "modules/StatusMessageModule.h"
|
||||
#endif
|
||||
#include "UIRenderer.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "gps/RTC.h" // for getTime() function
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/images.h"
|
||||
#include "meshUtils.h"
|
||||
#include <algorithm>
|
||||
@@ -97,41 +92,8 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
||||
|
||||
// 1) Choose target candidate (long vs short) only if present
|
||||
const char *raw = nullptr;
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_STATUS
|
||||
// If long-name mode is enabled, and we have a recent status for this node,
|
||||
// prefer "(short_name) statusText" as the raw candidate.
|
||||
std::string composedFromStatus;
|
||||
if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) {
|
||||
const auto &recent = statusMessageModule->getRecentReceived();
|
||||
const StatusMessageModule::RecentStatus *found = nullptr;
|
||||
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
|
||||
if (it->fromNodeId == node->num && !it->statusText.empty()) {
|
||||
found = &(*it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
const char *shortName = node->user.short_name;
|
||||
composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size());
|
||||
composedFromStatus += "(";
|
||||
if (shortName && *shortName) {
|
||||
composedFromStatus += shortName;
|
||||
}
|
||||
composedFromStatus += ") ";
|
||||
composedFromStatus += found->statusText;
|
||||
|
||||
raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// If we didn't compose from status, use normal long/short selection
|
||||
if (!raw) {
|
||||
if (node && node->has_user) {
|
||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
||||
}
|
||||
if (node && node->has_user) {
|
||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
||||
}
|
||||
|
||||
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
|
||||
@@ -215,33 +177,6 @@ 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
|
||||
// =============================
|
||||
@@ -256,9 +191,6 @@ 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];
|
||||
@@ -307,20 +239,14 @@ 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);
|
||||
constexpr int kBarCount = 4;
|
||||
constexpr int kBarWidth = 2;
|
||||
constexpr int kBarGap = 1;
|
||||
int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
|
||||
|
||||
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);
|
||||
@@ -342,48 +268,28 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
}
|
||||
}
|
||||
|
||||
const bool isZeroHop = node->has_hops_away && node->hops_away == 0;
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
for (int b = 0; b < 4; b++) {
|
||||
if (b < bars) {
|
||||
int height = (b * 2);
|
||||
display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Draw hop count
|
||||
char hopStr[6] = "";
|
||||
if (node->has_hops_away && node->hops_away > 0)
|
||||
snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away);
|
||||
|
||||
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);
|
||||
if (hopStr[0] != '\0') {
|
||||
int rightEdge = x + columnWidth - hopOffset;
|
||||
int textWidth = display->getStringWidth(hopStr);
|
||||
display->drawString(rightEdge - textWidth, y, hopStr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,9 +304,6 @@ 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] = "";
|
||||
|
||||
@@ -470,13 +373,14 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
}
|
||||
}
|
||||
|
||||
const char *distanceLabel = (strlen(distStr) > 0) ? distStr : "?";
|
||||
int offset = (currentResolution == ScreenResolution::High)
|
||||
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
||||
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
||||
int rightEdge = x + columnWidth - offset;
|
||||
int textWidth = display->getStringWidth(distanceLabel);
|
||||
display->drawString(rightEdge - textWidth, y, distanceLabel);
|
||||
if (strlen(distStr) > 0) {
|
||||
int offset = (currentResolution == ScreenResolution::High)
|
||||
? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
|
||||
: (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
|
||||
int rightEdge = x + columnWidth - offset;
|
||||
int textWidth = display->getStringWidth(distStr);
|
||||
display->drawString(rightEdge - textWidth, y, distStr);
|
||||
}
|
||||
}
|
||||
|
||||
void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
||||
@@ -506,9 +410,6 @@ 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);
|
||||
@@ -530,8 +431,8 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
}
|
||||
}
|
||||
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
||||
float myHeadingRadian, double userLat, double userLon)
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||
double userLat, double userLon)
|
||||
{
|
||||
if (!nodeDB->hasValidPosition(node))
|
||||
return;
|
||||
@@ -545,11 +446,11 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
double nodeLat = node->position.latitude_i * 1e-7;
|
||||
double nodeLon = node->position.longitude_i * 1e-7;
|
||||
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
|
||||
float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian);
|
||||
float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing);
|
||||
float bearingToNode = RAD_TO_DEG * bearing;
|
||||
float relativeBearing = fmod((bearingToNode - myHeading + 360), 360);
|
||||
// Shrink size by 2px
|
||||
int size = FONT_HEIGHT_SMALL - 5;
|
||||
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg);
|
||||
CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearing);
|
||||
/*
|
||||
float angle = relativeBearing * DEG_TO_RAD;
|
||||
float halfSize = size / 2.0;
|
||||
@@ -579,27 +480,12 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
*/
|
||||
}
|
||||
|
||||
void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float, double,
|
||||
double)
|
||||
{
|
||||
if (!nodeDB->hasValidPosition(node))
|
||||
return;
|
||||
|
||||
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
||||
int arrowXOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18);
|
||||
int centerX = x + columnWidth - arrowXOffset;
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(centerX, y, "?");
|
||||
}
|
||||
|
||||
// =============================
|
||||
// Main Screen Functions
|
||||
// =============================
|
||||
|
||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon)
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon)
|
||||
{
|
||||
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
|
||||
const int rowYOffset = FONT_HEIGHT_SMALL - 3;
|
||||
@@ -684,7 +570,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
renderer(display, node, xPos, yPos, columnWidth);
|
||||
|
||||
if (extras)
|
||||
extras(display, node, xPos, yPos, columnWidth, headingRadian, lat, lon);
|
||||
extras(display, node, xPos, yPos, columnWidth, heading, lat, lon);
|
||||
|
||||
lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
|
||||
yOffset += rowYOffset;
|
||||
@@ -764,9 +650,6 @@ 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);
|
||||
@@ -882,13 +765,9 @@ void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
#endif
|
||||
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
float headingRadian = 0.0f;
|
||||
float heading = 0;
|
||||
bool validHeading = false;
|
||||
auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (!ourNode || !nodeDB->hasValidPosition(ourNode)) {
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
double lat = DegD(ourNode->position.latitude_i);
|
||||
double lon = DegD(ourNode->position.longitude_i);
|
||||
|
||||
@@ -900,12 +779,21 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
|
||||
lastSwitchTime = now;
|
||||
}
|
||||
#endif
|
||||
if (!CompassRenderer::getHeadingRadians(lat, lon, headingRadian)) {
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, lat, lon);
|
||||
return;
|
||||
}
|
||||
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) {
|
||||
#if HAS_GPS
|
||||
if (screen->hasHeading()) {
|
||||
heading = screen->getHeading(); // degrees
|
||||
validHeading = true;
|
||||
} else {
|
||||
heading = screen->estimatedHeading(lat, lon);
|
||||
validHeading = !isnan(heading);
|
||||
}
|
||||
#endif
|
||||
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon);
|
||||
if (!validHeading)
|
||||
return;
|
||||
}
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
|
||||
}
|
||||
|
||||
/// Draw a series of fields in a column, wrapping to multiple columns if needed
|
||||
|
||||
@@ -32,7 +32,7 @@ enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATIO
|
||||
|
||||
// Main node list screen function
|
||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float headingRadian = 0, double lat = 0,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0,
|
||||
double lon = 0);
|
||||
|
||||
// Entry renderers
|
||||
@@ -43,8 +43,8 @@ void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
||||
void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
|
||||
|
||||
// Extras renderers
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
|
||||
float myHeadingRadian, double userLat, double userLon);
|
||||
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
||||
double userLat, double userLon);
|
||||
|
||||
// Screen frame functions
|
||||
void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
#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"
|
||||
@@ -610,9 +608,6 @@ 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;
|
||||
@@ -635,21 +630,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) {
|
||||
background_yOffset = -1;
|
||||
}
|
||||
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->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
|
||||
display->setColor(BLACK);
|
||||
int yOffset = 3;
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
@@ -669,7 +650,6 @@ 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;
|
||||
@@ -684,20 +664,6 @@ 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);
|
||||
|
||||
+336
-775
File diff suppressed because it is too large
Load Diff
@@ -50,12 +50,10 @@ class UIRenderer
|
||||
// Navigation bar overlay
|
||||
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
|
||||
static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
// Icon and screen drawing functions
|
||||
static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
/*
|
||||
|
||||
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
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
|
||||
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
@@ -104,15 +104,6 @@ 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 |
|
||||
|
||||
@@ -88,12 +88,8 @@ 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 = fontSmall.lineHeight() + 2; // scale with font so digit fits
|
||||
int radius = max(2, boxSize / 6);
|
||||
int boxSize = 11;
|
||||
int radius = 2; // rounded corner radius
|
||||
|
||||
// White halo background
|
||||
fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE);
|
||||
@@ -143,19 +143,17 @@ 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, r + 2, WHITE);
|
||||
drawCircle(centerX, centerY, r + 2, WHITE);
|
||||
fillCircle(centerX, centerY, 8, WHITE); // big white base
|
||||
drawCircle(centerX, centerY, 8, WHITE); // crisp edge
|
||||
|
||||
// Black bullseye on top
|
||||
drawCircle(centerX, centerY, r, BLACK);
|
||||
fillCircle(centerX, centerY, max(2, r / 4), BLACK);
|
||||
drawCircle(centerX, centerY, 6, BLACK);
|
||||
fillCircle(centerX, centerY, 2, BLACK);
|
||||
|
||||
// Crosshairs
|
||||
drawLine(centerX - r - 2, centerY, centerX + r + 2, centerY, BLACK);
|
||||
drawLine(centerX, centerY - r - 2, centerX, centerY + r + 2, BLACK);
|
||||
drawLine(centerX - 8, centerY, centerX + 8, centerY, BLACK);
|
||||
drawLine(centerX, centerY - 8, centerX, centerY + 8, BLACK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,9 +382,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
|
||||
uint16_t markerSizeMax = fontSmall.lineHeight(); // Scale cross with font
|
||||
uint16_t markerSizeMin = max(5, fontSmall.lineHeight() / 3);
|
||||
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;
|
||||
|
||||
int16_t textX;
|
||||
int16_t textY;
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
#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
|
||||
@@ -1,51 +0,0 @@
|
||||
#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,100 +1,155 @@
|
||||
#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()
|
||||
{
|
||||
mode = MODE_TEXT;
|
||||
lastTypingMode = MODE_TEXT;
|
||||
emotePage = 0;
|
||||
selectedKey = 0;
|
||||
prevSelectedKey = 0;
|
||||
normalizeSelection();
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onRender(bool full)
|
||||
{
|
||||
const bool showSelection = showSelectionHighlight();
|
||||
uint16_t em = fontSmall.lineHeight(); // 16 pt
|
||||
uint16_t keyH = Y(1.0) / KBD_ROWS;
|
||||
int16_t keyTopPadding = (keyH - fontSmall.lineHeight()) / 2;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
prevSelectedKey = selectedKey;
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::drawKey(uint8_t index, bool selected)
|
||||
// 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)
|
||||
{
|
||||
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;
|
||||
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 leftPadding = (width - textW) >> 1;
|
||||
printAt(left + leftPadding, top, label);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onForeground()
|
||||
{
|
||||
handleInput = true;
|
||||
mode = MODE_TEXT;
|
||||
lastTypingMode = MODE_TEXT;
|
||||
emotePage = 0;
|
||||
handleInput = true; // Intercept the button input for our applet
|
||||
|
||||
// Select the first key
|
||||
selectedKey = 0;
|
||||
prevSelectedKey = 0;
|
||||
normalizeSelection();
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onBackground()
|
||||
@@ -104,12 +159,32 @@ void InkHUD::KeyboardApplet::onBackground()
|
||||
|
||||
void InkHUD::KeyboardApplet::onButtonShortPress()
|
||||
{
|
||||
inputSelectedKey(false);
|
||||
char key = keys[selectedKey];
|
||||
if (key == '\n') {
|
||||
inkhud->freeTextDone();
|
||||
inkhud->closeKeyboard();
|
||||
} else if (key == '\x1b') {
|
||||
inkhud->freeTextCancel();
|
||||
inkhud->closeKeyboard();
|
||||
} else {
|
||||
inkhud->freeText(key);
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onButtonLongPress()
|
||||
{
|
||||
inputSelectedKey(true);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onExitShort()
|
||||
@@ -126,377 +201,57 @@ void InkHUD::KeyboardApplet::onExitLong()
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavUp()
|
||||
{
|
||||
if (selectedKey < KBD_COLS)
|
||||
if (selectedKey < KBD_COLS) // wrap
|
||||
selectedKey += KBD_COLS * (KBD_ROWS - 1);
|
||||
else
|
||||
else // move 1 row back
|
||||
selectedKey -= KBD_COLS;
|
||||
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh();
|
||||
// Request rendering over the previously drawn render
|
||||
requestUpdate(EInk::UpdateTypes::FAST, false);
|
||||
// Force an update to bypass lockRequests
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavDown()
|
||||
{
|
||||
selectedKey += KBD_COLS;
|
||||
selectedKey %= KBD_KEY_COUNT;
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh();
|
||||
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);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavLeft()
|
||||
{
|
||||
if (selectedKey % KBD_COLS == 0)
|
||||
if (selectedKey % KBD_COLS == 0) // wrap
|
||||
selectedKey += KBD_COLS - 1;
|
||||
else
|
||||
else // move 1 column back
|
||||
selectedKey--;
|
||||
|
||||
normalizeSelection();
|
||||
requestFastKeyboardRefresh();
|
||||
// Request rendering over the previously drawn render
|
||||
requestUpdate(EInk::UpdateTypes::FAST, false);
|
||||
// Force an update to bypass lockRequests
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
void InkHUD::KeyboardApplet::onNavRight()
|
||||
{
|
||||
if (selectedKey % KBD_COLS == KBD_COLS - 1)
|
||||
if (selectedKey % KBD_COLS == KBD_COLS - 1) // wrap
|
||||
selectedKey -= KBD_COLS - 1;
|
||||
else
|
||||
else // move 1 column forward
|
||||
selectedKey++;
|
||||
|
||||
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();
|
||||
// Request rendering over the previously drawn render
|
||||
requestUpdate(EInk::UpdateTypes::FAST, false);
|
||||
// Force an update to bypass lockRequests
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
}
|
||||
|
||||
uint16_t InkHUD::KeyboardApplet::getKeyboardHeight()
|
||||
{
|
||||
// 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));
|
||||
const uint16_t keyH = fontSmall.lineHeight() * 1.2;
|
||||
return keyH * KBD_ROWS;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
{
|
||||
|
||||
@@ -32,111 +31,34 @@ 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:
|
||||
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;
|
||||
void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color);
|
||||
|
||||
static const uint8_t KBD_COLS = 11;
|
||||
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]);
|
||||
static const uint8_t KBD_ROWS = 4;
|
||||
|
||||
// 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};
|
||||
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
|
||||
};
|
||||
|
||||
// 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};
|
||||
// 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
|
||||
};
|
||||
|
||||
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;
|
||||
uint16_t rowWidths[KBD_ROWS];
|
||||
uint8_t selectedKey = 0; // selected key index
|
||||
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,7 +109,6 @@ enum MenuAction {
|
||||
TOGGLE_CHANNEL_POSITION,
|
||||
SET_CHANNEL_PRECISION,
|
||||
// Display
|
||||
SET_DISPLAY_TIMEOUT,
|
||||
TOGGLE_DISPLAY_UNITS,
|
||||
// Network
|
||||
TOGGLE_WIFI,
|
||||
@@ -120,4 +119,4 @@ enum MenuAction {
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -8,7 +8,6 @@
|
||||
#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"
|
||||
@@ -28,16 +27,6 @@ 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;
|
||||
@@ -50,77 +39,6 @@ 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
|
||||
@@ -129,11 +47,7 @@ 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
|
||||
@@ -162,11 +76,9 @@ 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
|
||||
@@ -194,11 +106,9 @@ 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
|
||||
@@ -267,8 +177,24 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
if (!owner.is_licensed) {
|
||||
bool keygenSuccess = false;
|
||||
|
||||
if (config.security.private_key.size == 32) {
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
} else {
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -423,14 +349,17 @@ 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
|
||||
if (supportsFreeTextKeyboard(inkhud, settings))
|
||||
// Open the on-screen keyboard only for full joystick devices
|
||||
if (settings->joystick.enabled && !inkhud->twoWayRocker)
|
||||
inkhud->openKeyboard();
|
||||
break;
|
||||
|
||||
case STORE_CANNEDMESSAGE_SELECTION: {
|
||||
const uint8_t prefixItems = supportsFreeTextKeyboard(inkhud, settings) ? 2 : 1;
|
||||
cm.selectedMessageItem = &cm.messageItems.at(cursor - prefixItems);
|
||||
} 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 SEND_CANNEDMESSAGE:
|
||||
cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
|
||||
@@ -509,27 +438,14 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
break;
|
||||
|
||||
case TOGGLE_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();
|
||||
// Note: backlight is already on in this situation
|
||||
// We're marking that it should *remain* on once menu closes
|
||||
assert(backlight);
|
||||
if (backlight->isLatched())
|
||||
backlight->off();
|
||||
else
|
||||
backlight->latch();
|
||||
if (item.checkState)
|
||||
*(item.checkState) = backlight->isLatched();
|
||||
#endif
|
||||
break;
|
||||
break;
|
||||
|
||||
case TOGGLE_12H_CLOCK:
|
||||
config.display.use_12h_clock = !config.display.use_12h_clock;
|
||||
@@ -627,17 +543,6 @@ 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;
|
||||
@@ -1004,16 +909,11 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
previousPage = MenuPage::ROOT;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
// Optional: backlight
|
||||
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));
|
||||
}
|
||||
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
|
||||
));
|
||||
|
||||
// Options Toggles
|
||||
items.push_back(MenuItem("Applets", MenuPage::APPLETS));
|
||||
@@ -1225,9 +1125,6 @@ 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";
|
||||
|
||||
@@ -1237,13 +1134,6 @@ 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));
|
||||
@@ -1512,14 +1402,10 @@ 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 = getMenuItemHeightPx(inkhud);
|
||||
const int16_t selectInsetY = touchFriendlyLayout ? 3 : 2;
|
||||
const uint16_t itemH = fontSmall.lineHeight() * 1.6;
|
||||
const int16_t selectInsetY = 2;
|
||||
const int16_t itemW = width() - X(padding) - X(padding);
|
||||
const int16_t itemL = X(padding);
|
||||
const int16_t itemR = X(1 - padding);
|
||||
@@ -1552,11 +1438,6 @@ 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
|
||||
// ===================
|
||||
|
||||
@@ -1584,6 +1465,8 @@ 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);
|
||||
|
||||
@@ -1592,17 +1475,8 @@ 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 && (!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);
|
||||
}
|
||||
}
|
||||
if (cursorShown && i == cursor)
|
||||
drawRect(itemL, itemT + selectInsetY, itemW, itemH - (selectInsetY * 2), BLACK);
|
||||
|
||||
// Indented normal item text
|
||||
printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE);
|
||||
@@ -1610,9 +1484,9 @@ void InkHUD::MenuApplet::onRender(bool full)
|
||||
|
||||
// Checkbox, if relevant
|
||||
if (item.checkState) {
|
||||
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
|
||||
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
|
||||
// Checkbox ticked
|
||||
if (*(item.checkState)) {
|
||||
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
|
||||
@@ -1641,102 +1515,13 @@ 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);
|
||||
|
||||
// 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 (!settings->joystick.enabled) {
|
||||
if (!cursorShown) {
|
||||
cursorShown = true;
|
||||
// Select the first item that isn't a header
|
||||
@@ -1797,32 +1582,6 @@ 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
|
||||
@@ -1852,29 +1611,6 @@ 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
|
||||
@@ -2007,17 +1743,6 @@ 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()
|
||||
@@ -2025,8 +1750,8 @@ void InkHUD::MenuApplet::populateSendPage()
|
||||
// Position / NodeInfo packet
|
||||
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
|
||||
|
||||
// Show the Free Text option on any node that supports the on-screen keyboard.
|
||||
if (supportsFreeTextKeyboard(inkhud, settings))
|
||||
// If joystick is available, include the Free Text option
|
||||
if (settings->joystick.enabled && !inkhud->twoWayRocker)
|
||||
items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND));
|
||||
|
||||
// One menu item for each canned message
|
||||
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
@@ -49,12 +48,11 @@ 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 populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs
|
||||
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 drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height,
|
||||
const std::string &text); // Draw input field for free text
|
||||
@@ -67,9 +65,8 @@ 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)
|
||||
bool hideTouchSelectionHighlight = false; // Touch scrolling keeps cursor for paging math, but can hide highlight
|
||||
uint8_t cursor = 0; // Which menu item is currently highlighted
|
||||
bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
|
||||
bool freeTextMode = false;
|
||||
uint16_t systemInfoPanelHeight = 0; // Need to know before we render
|
||||
uint16_t menuTextLimit = 200;
|
||||
@@ -83,8 +80,6 @@ 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
|
||||
@@ -121,8 +116,7 @@ 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 keepBacklightOn = false; // Helper to display current backlight latch state in InkHUD options
|
||||
bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
@@ -32,7 +32,6 @@ 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,
|
||||
@@ -46,4 +45,4 @@ enum MenuPage : uint8_t {
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,30 +0,0 @@
|
||||
#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
|
||||
@@ -1,29 +0,0 @@
|
||||
#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,9 +47,6 @@ 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();
|
||||
@@ -82,7 +79,7 @@ void InkHUD::TipsApplet::onRender(bool full)
|
||||
cursorY += fontSmall.lineHeight() / 2;
|
||||
drawBullet("More info at meshtastic.org");
|
||||
|
||||
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
|
||||
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
|
||||
} break;
|
||||
|
||||
case Tip::PICK_REGION: {
|
||||
@@ -112,7 +109,7 @@ void InkHUD::TipsApplet::onRender(bool full)
|
||||
printWrapped(0, cursorY, width(), body);
|
||||
cursorY += bodyH + (fontSmall.lineHeight() / 2);
|
||||
|
||||
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
|
||||
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
|
||||
} break;
|
||||
|
||||
case Tip::CUSTOMIZATION: {
|
||||
@@ -132,13 +129,10 @@ void InkHUD::TipsApplet::onRender(bool full)
|
||||
printWrapped(0, cursorY, width(), body);
|
||||
cursorY += bodyH + (fontSmall.lineHeight() / 2);
|
||||
|
||||
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
|
||||
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
|
||||
} break;
|
||||
|
||||
case Tip::BUTTONS: {
|
||||
#if defined(T5_S3_EPAPER_PRO)
|
||||
renderT5S3ButtonsTip();
|
||||
#else
|
||||
setFont(fontMedium);
|
||||
|
||||
const char *title = "Tip: Buttons";
|
||||
@@ -170,8 +164,7 @@ void InkHUD::TipsApplet::onRender(bool full)
|
||||
drawBullet("- press: switch tile or close menu");
|
||||
}
|
||||
|
||||
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
|
||||
#endif
|
||||
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
|
||||
} break;
|
||||
|
||||
case Tip::ROTATION: {
|
||||
@@ -196,7 +189,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), continuePrompt, LEFT, BOTTOM);
|
||||
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
|
||||
|
||||
// Revert the "flip screen" setting, preventing this message showing again
|
||||
config.display.flip_screen = false;
|
||||
@@ -205,42 +198,6 @@ 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()
|
||||
@@ -287,9 +244,7 @@ void InkHUD::TipsApplet::renderWelcome()
|
||||
|
||||
// Block 3 - press to continue
|
||||
// ============================
|
||||
const char *continuePrompt =
|
||||
(inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue";
|
||||
printAt(X(0.5), Y(1), continuePrompt, CENTER, BOTTOM);
|
||||
printAt(X(0.5), Y(1), "Press button to continue", CENTER, BOTTOM);
|
||||
}
|
||||
|
||||
void InkHUD::TipsApplet::onForeground()
|
||||
|
||||
@@ -41,9 +41,6 @@ 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
|
||||
|
||||
@@ -52,4 +49,4 @@ class TipsApplet : public SystemApplet
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "./Events.h"
|
||||
|
||||
#include "PowerFSM.h"
|
||||
#include "RTC.h"
|
||||
#include "buzz.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
@@ -15,19 +14,6 @@
|
||||
|
||||
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
|
||||
@@ -52,8 +38,6 @@ void InkHUD::Events::begin()
|
||||
|
||||
void InkHUD::Events::onButtonShort()
|
||||
{
|
||||
noteInkHUDUserInteraction();
|
||||
|
||||
// Audio feedback (via buzzer)
|
||||
// Short tone
|
||||
playChirp();
|
||||
@@ -90,8 +74,6 @@ void InkHUD::Events::onButtonShort()
|
||||
|
||||
void InkHUD::Events::onButtonLong()
|
||||
{
|
||||
noteInkHUDUserInteraction();
|
||||
|
||||
// Audio feedback (via buzzer)
|
||||
// Slightly longer than playChirp
|
||||
playBoop();
|
||||
@@ -120,295 +102,193 @@ void InkHUD::Events::onButtonLong()
|
||||
|
||||
void InkHUD::Events::onExitShort()
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
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();
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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();
|
||||
|
||||
// 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();
|
||||
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT))
|
||||
userConsumer->onExitShort();
|
||||
else
|
||||
inkhud->nextTile();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::Events::onExitLong()
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
if (settings->joystick.enabled) {
|
||||
// Audio feedback (via buzzer)
|
||||
// Slightly longer than playChirp
|
||||
playBoop();
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always allow system applets to consume EXIT/HOME long-press.
|
||||
if (consumer) {
|
||||
consumer->onExitLong();
|
||||
} else {
|
||||
Applet *userConsumer = inkhud->getActiveApplet();
|
||||
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)
|
||||
onTouchNavUp();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::Events::onNavDown()
|
||||
{
|
||||
if (settings->joystick.enabled)
|
||||
onTouchNavDown();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::Events::onNavLeft()
|
||||
{
|
||||
if (settings->joystick.enabled)
|
||||
onTouchNavLeft();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::Events::onNavRight()
|
||||
{
|
||||
if (settings->joystick.enabled)
|
||||
onTouchNavRight();
|
||||
}
|
||||
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();
|
||||
|
||||
void InkHUD::Events::onTouchNavUp()
|
||||
{
|
||||
noteInkHUDUserInteraction();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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();
|
||||
|
||||
// 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 (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
|
||||
userConsumer->onNavRight();
|
||||
else
|
||||
inkhud->nextApplet();
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -420,8 +300,6 @@ 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) {
|
||||
@@ -433,8 +311,6 @@ 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) {
|
||||
@@ -611,4 +487,4 @@ bool InkHUD::Events::dismissExternalNotification()
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
{
|
||||
@@ -31,17 +30,12 @@ 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 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
|
||||
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
|
||||
|
||||
// Free text typing events
|
||||
void onFreeText(char c); // New freetext character input
|
||||
@@ -85,11 +79,8 @@ 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
|
||||
@@ -73,24 +73,6 @@ 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()
|
||||
@@ -193,92 +175,6 @@ 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)
|
||||
@@ -327,12 +223,6 @@ 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()
|
||||
{
|
||||
@@ -365,16 +255,6 @@ 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()
|
||||
{
|
||||
@@ -496,4 +376,4 @@ void InkHUD::InkHUD::drawPixel(int16_t x, int16_t y, Color c)
|
||||
renderer->handlePixel(x, y, c);
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -39,8 +39,6 @@ class WindowManager;
|
||||
class InkHUD
|
||||
{
|
||||
public:
|
||||
using TouchEnabledProvider = bool (*)();
|
||||
|
||||
static InkHUD *getInstance(); // Access to this singleton class
|
||||
|
||||
// Configuration
|
||||
@@ -53,11 +51,6 @@ class InkHUD
|
||||
|
||||
void begin();
|
||||
|
||||
// Optional touch-state provider for reusable touch status indicators.
|
||||
void setTouchEnabledProvider(TouchEnabledProvider provider);
|
||||
bool hasTouchEnabledProvider() const;
|
||||
bool isTouchEnabled() const;
|
||||
|
||||
// Handle user-button press
|
||||
// - connected to an input source, in variant nicheGraphics.h
|
||||
|
||||
@@ -69,12 +62,6 @@ class InkHUD
|
||||
void navDown();
|
||||
void navLeft();
|
||||
void navRight();
|
||||
void touchNavUp();
|
||||
void touchNavDown();
|
||||
void touchNavLeft();
|
||||
void touchNavRight();
|
||||
void touchTap(uint16_t x, uint16_t y);
|
||||
void touchLongPress(uint16_t x, uint16_t y);
|
||||
|
||||
// Freetext handlers
|
||||
void freeText(char c);
|
||||
@@ -89,14 +76,11 @@ class InkHUD
|
||||
void prevApplet();
|
||||
NicheGraphics::InkHUD::Applet *getActiveApplet();
|
||||
void openMenu();
|
||||
void openAppSwitcher();
|
||||
void openAlignStick();
|
||||
void openKeyboard();
|
||||
void closeKeyboard();
|
||||
void nextTile();
|
||||
void prevTile();
|
||||
bool showApplet(uint8_t appletIndex);
|
||||
bool selectTileAt(uint16_t x, uint16_t y);
|
||||
void rotate();
|
||||
void rotateJoystick(uint8_t angle = 1); // rotate 90 deg by default
|
||||
void toggleBatteryIcon();
|
||||
@@ -145,7 +129,6 @@ class InkHUD
|
||||
Events *events = nullptr; // Handle non-specific firmware events
|
||||
Renderer *renderer = nullptr; // Co-ordinate display updates
|
||||
WindowManager *windowManager = nullptr; // Multiplexing of applets
|
||||
TouchEnabledProvider touchEnabledProvider = nullptr;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
@@ -142,4 +142,4 @@ class Persistence
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -3,13 +3,11 @@
|
||||
#include "./WindowManager.h"
|
||||
|
||||
#include "./Applets/System/AlignStick/AlignStickApplet.h"
|
||||
#include "./Applets/System/AppSwitcher/AppSwitcherApplet.h"
|
||||
#include "./Applets/System/BatteryIcon/BatteryIconApplet.h"
|
||||
#include "./Applets/System/Keyboard/KeyboardApplet.h"
|
||||
#include "./Applets/System/Logo/LogoApplet.h"
|
||||
#include "./Applets/System/Menu/MenuApplet.h"
|
||||
#include "./Applets/System/Notification/NotificationApplet.h"
|
||||
#include "./Applets/System/Notification/TouchStatusApplet.h"
|
||||
#include "./Applets/System/Pairing/PairingApplet.h"
|
||||
#include "./Applets/System/Placeholder/PlaceholderApplet.h"
|
||||
#include "./Applets/System/Tips/TipsApplet.h"
|
||||
@@ -17,14 +15,6 @@
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
namespace
|
||||
{
|
||||
bool supportsOnScreenKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
|
||||
{
|
||||
return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
InkHUD::WindowManager::WindowManager()
|
||||
{
|
||||
// Convenient references
|
||||
@@ -142,38 +132,6 @@ void InkHUD::WindowManager::prevTile()
|
||||
userTiles.at(settings->userTiles.focused)->requestHighlight();
|
||||
}
|
||||
|
||||
// Focus the user tile containing a touch coordinate.
|
||||
// Returns true only when the focused tile changes.
|
||||
bool InkHUD::WindowManager::selectTileAt(uint16_t x, uint16_t y)
|
||||
{
|
||||
if (userTiles.size() < 2)
|
||||
return false;
|
||||
|
||||
const int32_t tx = x;
|
||||
const int32_t ty = y;
|
||||
|
||||
for (uint8_t i = 0; i < userTiles.size(); i++) {
|
||||
Tile *tile = userTiles.at(i);
|
||||
|
||||
const int32_t left = tile->getLeft();
|
||||
const int32_t top = tile->getTop();
|
||||
const int32_t right = left + tile->getWidth();
|
||||
const int32_t bottom = top + tile->getHeight();
|
||||
|
||||
if (tx < left || tx >= right || ty < top || ty >= bottom)
|
||||
continue;
|
||||
|
||||
if (settings->userTiles.focused == i)
|
||||
return false;
|
||||
|
||||
settings->userTiles.focused = i;
|
||||
refocusTile();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show the menu (on the the focused tile)
|
||||
// The applet previously displayed there will be restored once the menu closes
|
||||
void InkHUD::WindowManager::openMenu()
|
||||
@@ -182,18 +140,6 @@ void InkHUD::WindowManager::openMenu()
|
||||
menu->show(userTiles.at(settings->userTiles.focused));
|
||||
}
|
||||
|
||||
// Show touch-only app switcher on the focused tile
|
||||
void InkHUD::WindowManager::openAppSwitcher()
|
||||
{
|
||||
if (!inkhud->hasTouchEnabledProvider())
|
||||
return;
|
||||
|
||||
AppSwitcherApplet *switcher = static_cast<AppSwitcherApplet *>(inkhud->getSystemApplet("AppSwitcher"));
|
||||
if (switcher) {
|
||||
switcher->show(userTiles.at(settings->userTiles.focused));
|
||||
}
|
||||
}
|
||||
|
||||
// Bring the AlignStick applet to the foreground
|
||||
void InkHUD::WindowManager::openAlignStick()
|
||||
{
|
||||
@@ -205,7 +151,7 @@ void InkHUD::WindowManager::openAlignStick()
|
||||
|
||||
void InkHUD::WindowManager::openKeyboard()
|
||||
{
|
||||
if (!supportsOnScreenKeyboard(inkhud, settings))
|
||||
if (!settings->joystick.enabled || inkhud->twoWayRocker)
|
||||
return;
|
||||
|
||||
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
|
||||
@@ -219,7 +165,7 @@ void InkHUD::WindowManager::openKeyboard()
|
||||
|
||||
void InkHUD::WindowManager::closeKeyboard()
|
||||
{
|
||||
if (!supportsOnScreenKeyboard(inkhud, settings))
|
||||
if (!settings->joystick.enabled || inkhud->twoWayRocker)
|
||||
return;
|
||||
|
||||
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
|
||||
@@ -333,41 +279,6 @@ void InkHUD::WindowManager::prevApplet()
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST); // bringToForeground already requested, but we're manually forcing FAST
|
||||
}
|
||||
|
||||
// Show a specific applet on the focused tile, or focus the tile where it is already shown.
|
||||
bool InkHUD::WindowManager::showApplet(uint8_t appletIndex)
|
||||
{
|
||||
if (appletIndex >= inkhud->userApplets.size())
|
||||
return false;
|
||||
|
||||
Applet *target = inkhud->userApplets.at(appletIndex);
|
||||
if (!target || !target->isActive())
|
||||
return false;
|
||||
|
||||
// If target is already visible on another tile, just focus that tile.
|
||||
for (uint8_t i = 0; i < userTiles.size(); i++) {
|
||||
if (userTiles.at(i)->getAssignedApplet() == target) {
|
||||
settings->userTiles.focused = i;
|
||||
refocusTile();
|
||||
if (!settings->optionalMenuItems.nextTile)
|
||||
userTiles.at(settings->userTiles.focused)->requestHighlight();
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise replace the focused tile's applet.
|
||||
Tile *focused = userTiles.at(settings->userTiles.focused);
|
||||
Applet *current = focused->getAssignedApplet();
|
||||
if (current && current != target)
|
||||
current->sendToBackground();
|
||||
|
||||
focused->assignApplet(target);
|
||||
target->bringToForeground();
|
||||
settings->userTiles.displayedUserApplet[settings->userTiles.focused] = appletIndex;
|
||||
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns active applet
|
||||
NicheGraphics::InkHUD::Applet *InkHUD::WindowManager::getActiveApplet()
|
||||
{
|
||||
@@ -574,21 +485,14 @@ void InkHUD::WindowManager::createSystemApplets()
|
||||
addSystemApplet("Tips", new TipsApplet, new Tile);
|
||||
if (settings->joystick.enabled && !inkhud->twoWayRocker) {
|
||||
addSystemApplet("AlignStick", new AlignStickApplet, new Tile);
|
||||
}
|
||||
if (supportsOnScreenKeyboard(inkhud, settings)) {
|
||||
addSystemApplet("Keyboard", new KeyboardApplet, new Tile);
|
||||
}
|
||||
if (inkhud->hasTouchEnabledProvider()) {
|
||||
addSystemApplet("AppSwitcher", new AppSwitcherApplet, nullptr);
|
||||
}
|
||||
|
||||
addSystemApplet("Menu", new MenuApplet, nullptr);
|
||||
|
||||
// Battery and notifications *behind* the menu
|
||||
addSystemApplet("Notification", new NotificationApplet, new Tile);
|
||||
addSystemApplet("BatteryIcon", new BatteryIconApplet, new Tile);
|
||||
if (inkhud->hasTouchEnabledProvider())
|
||||
addSystemApplet("TouchStatus", new TouchStatusApplet, new Tile);
|
||||
|
||||
// Special handling only, via Rendering::renderPlaceholders
|
||||
addSystemApplet("Placeholder", new PlaceholderApplet, nullptr);
|
||||
@@ -607,8 +511,6 @@ void InkHUD::WindowManager::placeSystemTiles()
|
||||
inkhud->getSystemApplet("Tips")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
|
||||
if (settings->joystick.enabled && !inkhud->twoWayRocker) {
|
||||
inkhud->getSystemApplet("AlignStick")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
|
||||
}
|
||||
if (supportsOnScreenKeyboard(inkhud, settings)) {
|
||||
const uint16_t keyboardHeight = KeyboardApplet::getKeyboardHeight();
|
||||
inkhud->getSystemApplet("Keyboard")
|
||||
->getTile()
|
||||
@@ -625,17 +527,6 @@ void InkHUD::WindowManager::placeSystemTiles()
|
||||
batteryIconWidth + 1, // width
|
||||
batteryIconHeight + 2); // height
|
||||
|
||||
if (inkhud->hasTouchEnabledProvider()) {
|
||||
const uint16_t touchStatusH = Applet::fontSmall.lineHeight() + 4;
|
||||
inkhud->getSystemApplet("TouchStatus")
|
||||
->getTile()
|
||||
->setRegion(0, inkhud->height() - touchStatusH, inkhud->width(), touchStatusH);
|
||||
if (inkhud->isTouchEnabled())
|
||||
inkhud->getSystemApplet("TouchStatus")->sendToBackground();
|
||||
else
|
||||
inkhud->getSystemApplet("TouchStatus")->bringToForeground();
|
||||
}
|
||||
|
||||
// Note: the tiles of placeholder and menu applets are manipulated specially
|
||||
// - menuApplet borrows user tiles
|
||||
// - placeholder applet is temporarily assigned to each user tile of WindowManager::getEmptyTiles
|
||||
|
||||
@@ -29,16 +29,13 @@ class WindowManager
|
||||
|
||||
void nextTile();
|
||||
void prevTile();
|
||||
bool selectTileAt(uint16_t x, uint16_t y);
|
||||
Applet *getActiveApplet();
|
||||
void openMenu();
|
||||
void openAlignStick();
|
||||
void openAppSwitcher();
|
||||
void openKeyboard();
|
||||
void closeKeyboard();
|
||||
void nextApplet();
|
||||
void prevApplet();
|
||||
bool showApplet(uint8_t appletIndex);
|
||||
void rotate();
|
||||
void toggleBatteryIcon();
|
||||
|
||||
@@ -79,4 +76,4 @@ class WindowManager
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -143,10 +143,6 @@ int32_t ButtonThread::runOnce()
|
||||
leadUpSequenceActive = false;
|
||||
resetLeadUpSequence();
|
||||
}
|
||||
#ifdef INPUT_DEBUG
|
||||
if (buttonCurrentlyPressed)
|
||||
LOG_WARN("Button held for %u ms", millis() - buttonPressStartTime);
|
||||
#endif
|
||||
|
||||
// Progressive lead-up sound system
|
||||
if (!_suppressLeadUp && buttonCurrentlyPressed && (millis() - buttonPressStartTime) >= BUTTON_LEADUP_MS) {
|
||||
@@ -315,8 +311,7 @@ int32_t ButtonThread::runOnce()
|
||||
void ButtonThread::attachButtonInterrupts()
|
||||
{
|
||||
// Interrupt for user button, during normal use. Improves responsiveness.
|
||||
if (_intRoutine != nullptr)
|
||||
attachInterrupt(_pinNum, _intRoutine, CHANGE);
|
||||
attachInterrupt(_pinNum, _intRoutine, CHANGE);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -325,8 +320,7 @@ void ButtonThread::attachButtonInterrupts()
|
||||
*/
|
||||
void ButtonThread::detachButtonInterrupts()
|
||||
{
|
||||
if (_intRoutine != nullptr)
|
||||
detachInterrupt(_pinNum);
|
||||
detachInterrupt(_pinNum);
|
||||
}
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
|
||||
@@ -390,11 +390,8 @@ void InputBroker::Init()
|
||||
seesawRotary = nullptr;
|
||||
}
|
||||
}
|
||||
#ifdef __linux__
|
||||
// Linux evdev keyboard input only — macOS has no <linux/input.h>.
|
||||
aLinuxInputImpl = new LinuxInputImpl();
|
||||
aLinuxInputImpl->init();
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_INPUTBROKER && HAS_TRACKBALL
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
#pragma once
|
||||
// Linux evdev keyboard input. Only compiled on Linux portduino targets;
|
||||
// macOS / non-Linux builds have no <linux/input.h> or epoll, and the
|
||||
// headless build doesn't need real keyboards anyway.
|
||||
#if ARCH_PORTDUINO && defined(__linux__)
|
||||
#if ARCH_PORTDUINO
|
||||
#include "InputBroker.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
#include <assert.h>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Linux evdev impl. Same Linux-only gating as LinuxInput.h.
|
||||
#if defined(ARCH_PORTDUINO) && defined(__linux__)
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#pragma once
|
||||
#include "LinuxInput.h"
|
||||
#include "main.h"
|
||||
|
||||
@@ -9,35 +9,6 @@
|
||||
#define TIME_LONG_PRESS 400
|
||||
#endif
|
||||
|
||||
// Touch sampling cadence (milliseconds).
|
||||
// Can be overridden by board variants for faster touch panels.
|
||||
#ifndef TOUCH_POLL_INTERVAL_IDLE
|
||||
#define TOUCH_POLL_INTERVAL_IDLE 100
|
||||
#endif
|
||||
|
||||
#ifndef TOUCH_POLL_INTERVAL_ACTIVE
|
||||
#define TOUCH_POLL_INTERVAL_ACTIVE 20
|
||||
#endif
|
||||
|
||||
#ifndef TOUCH_POLL_INTERVAL_RELEASE
|
||||
#define TOUCH_POLL_INTERVAL_RELEASE 50
|
||||
#endif
|
||||
|
||||
// Faster cadence used for keyboard-like tap-heavy UIs.
|
||||
#ifndef TOUCH_POLL_INTERVAL_ACTIVE_FAST
|
||||
#define TOUCH_POLL_INTERVAL_ACTIVE_FAST TOUCH_POLL_INTERVAL_ACTIVE
|
||||
#endif
|
||||
|
||||
#ifndef TOUCH_POLL_INTERVAL_RELEASE_FAST
|
||||
#define TOUCH_POLL_INTERVAL_RELEASE_FAST TOUCH_POLL_INTERVAL_RELEASE
|
||||
#endif
|
||||
|
||||
// Ignore very short "finger lifted" glitches from noisy touch controllers.
|
||||
// A release is only accepted once we've seen no-touch for at least this duration.
|
||||
#ifndef TOUCH_RELEASE_GRACE_MS
|
||||
#define TOUCH_RELEASE_GRACE_MS 35
|
||||
#endif
|
||||
|
||||
// move a minimum distance over the screen to detect a "swipe"
|
||||
#ifndef TOUCH_THRESHOLD_X
|
||||
#define TOUCH_THRESHOLD_X 30
|
||||
@@ -49,7 +20,7 @@
|
||||
|
||||
TouchScreenBase::TouchScreenBase(const char *name, uint16_t width, uint16_t height)
|
||||
: concurrency::OSThread(name), _display_width(width), _display_height(height), _first_x(0), _last_x(0), _first_y(0),
|
||||
_last_y(0), _start(0), _lastTouchSeenMs(0), _tapped(false), _originName(name)
|
||||
_last_y(0), _start(0), _tapped(false), _originName(name)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -57,7 +28,7 @@ void TouchScreenBase::init(bool hasTouch)
|
||||
{
|
||||
if (hasTouch) {
|
||||
LOG_INFO("TouchScreen initialized %d %d", TOUCH_THRESHOLD_X, TOUCH_THRESHOLD_Y);
|
||||
this->setInterval(TOUCH_POLL_INTERVAL_IDLE);
|
||||
this->setInterval(100);
|
||||
} else {
|
||||
disable();
|
||||
this->setInterval(UINT_MAX);
|
||||
@@ -68,8 +39,6 @@ int32_t TouchScreenBase::runOnce()
|
||||
{
|
||||
TouchEvent e;
|
||||
e.touchEvent = static_cast<char>(TOUCH_ACTION_NONE);
|
||||
const bool fastTapMode = fastTapModeEnabled();
|
||||
const bool allowLongPress = longPressEnabled();
|
||||
|
||||
// process touch events
|
||||
int16_t x, y;
|
||||
@@ -77,13 +46,9 @@ int32_t TouchScreenBase::runOnce()
|
||||
if (x < 0 || y < 0) // T-deck can emit phantom touch events with a negative value when turning off the screen
|
||||
touched = false;
|
||||
if (touched) {
|
||||
_lastTouchSeenMs = millis();
|
||||
this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_ACTIVE_FAST : TOUCH_POLL_INTERVAL_ACTIVE);
|
||||
this->setInterval(20);
|
||||
_last_x = x;
|
||||
_last_y = y;
|
||||
} else if (_touchedOld && ((uint32_t)millis() - _lastTouchSeenMs) < TOUCH_RELEASE_GRACE_MS) {
|
||||
// Treat brief no-touch samples as continuous touch to preserve long-press detection.
|
||||
touched = true;
|
||||
}
|
||||
if (touched != _touchedOld) {
|
||||
if (touched) {
|
||||
@@ -97,7 +62,7 @@ int32_t TouchScreenBase::runOnce()
|
||||
time_t duration = millis() - _start;
|
||||
x = _last_x;
|
||||
y = _last_y;
|
||||
this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_RELEASE_FAST : TOUCH_POLL_INTERVAL_RELEASE);
|
||||
this->setInterval(50);
|
||||
|
||||
// compute distance
|
||||
int16_t dx = x - _first_x;
|
||||
@@ -127,7 +92,7 @@ int32_t TouchScreenBase::runOnce()
|
||||
}
|
||||
// tap
|
||||
else {
|
||||
if (duration > 0 && (duration < TIME_LONG_PRESS || !allowLongPress)) {
|
||||
if (duration > 0 && duration < TIME_LONG_PRESS) {
|
||||
if (_tapped) {
|
||||
_tapped = false;
|
||||
} else {
|
||||
@@ -167,7 +132,7 @@ int32_t TouchScreenBase::runOnce()
|
||||
#endif
|
||||
|
||||
// fire LONG_PRESS event without the need for release
|
||||
if (allowLongPress && touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) {
|
||||
if (touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) {
|
||||
// tricky: prevent reoccurring events and another touch event when releasing
|
||||
_start = millis() + 30000;
|
||||
e.touchEvent = static_cast<char>(TOUCH_ACTION_LONG_PRESS);
|
||||
@@ -192,13 +157,3 @@ void TouchScreenBase::hapticFeedback()
|
||||
drv.go();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool TouchScreenBase::fastTapModeEnabled() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool TouchScreenBase::longPressEnabled() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ class TouchScreenBase : public Observable<const InputEvent *>, public concurrenc
|
||||
|
||||
virtual bool getTouch(int16_t &x, int16_t &y) = 0;
|
||||
virtual void onEvent(const TouchEvent &event) = 0;
|
||||
virtual bool fastTapModeEnabled() const;
|
||||
virtual bool longPressEnabled() const;
|
||||
|
||||
volatile TouchScreenBaseStateType _state = TOUCH_EVENT_CLEARED;
|
||||
volatile TouchScreenBaseEventType _action = TOUCH_ACTION_NONE;
|
||||
@@ -51,7 +49,6 @@ class TouchScreenBase : public Observable<const InputEvent *>, public concurrenc
|
||||
int16_t _first_x, _last_x; // horizontal swipe direction
|
||||
int16_t _first_y, _last_y; // vertical swipe direction
|
||||
time_t _start; // for LONG_PRESS
|
||||
uint32_t _lastTouchSeenMs; // helps suppress brief touch-controller dropouts
|
||||
bool _tapped; // for DOUBLE_TAP
|
||||
|
||||
const char *_originName;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user