Compare commits

..
Author SHA1 Message Date
Jonathan Bennett d9887da265 Remove incorrect LED_STATE_ON definition for t-beam-s3
Fixes #9912  and #10170
2026-04-23 19:47:32 -05:00
178 changed files with 1745 additions and 9655 deletions
+1 -5
View File
@@ -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
@@ -370,7 +369,7 @@ To reduce avoidable agent mistakes, assume these tools are available (or install
- **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`)
- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts)
Fallback expectations for agents:
@@ -389,7 +388,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
@@ -575,8 +573,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.
+3 -8
View File
@@ -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
-51
View File
@@ -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
+1 -3
View File
@@ -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 }}
-22
View File
@@ -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 }}
-15
View File
@@ -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
+5 -5
View File
@@ -4,19 +4,19 @@ 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
- checkov@3.2.524
- renovate@43.139.6
- prettier@3.8.3
- trufflehog@3.95.2
- yamllint@1.38.0
- bandit@1.9.4
- trivy@0.70.0
- taplo@0.10.0
- ruff@0.15.12
- ruff@0.15.11
- isort@8.0.1
- markdownlint@0.48.0
- oxipng@10.1.1
@@ -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:
+24 -27
View File
@@ -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)
@@ -122,21 +121,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
View File
@@ -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
View File
@@ -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
View File
@@ -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/
-118
View File
@@ -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
-43
View File
@@ -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"
}
-1
View File
@@ -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
View File
@@ -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
+12 -157
View File
@@ -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:
+3 -63
View File
@@ -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
+1 -18
View File
@@ -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}
+1 -6
View File
@@ -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
+3 -4
View File
@@ -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
@@ -67,7 +66,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 +119,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]
+90 -60
View File
@@ -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
}
-1
View File
@@ -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);
+16 -16
View File
@@ -781,10 +781,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 +1000,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 +1016,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();
}
@@ -1094,7 +1091,7 @@ void Power::attachPowerInterrupts()
if (PMU) {
attachInterrupt(
PMU_IRQ,
[]() {
[] {
pmu_irq = true;
power->setIntervalFromNow(0);
runASAP = true;
@@ -1397,16 +1394,19 @@ 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);
// 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();
+11 -48
View File
@@ -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");
-21
View File
@@ -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
@@ -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 -6
View File
@@ -1,8 +1,8 @@
#pragma once
#include "../freertosinc.h"
#include "Print.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <Print.h>
#include <stdarg.h>
#include <string>
@@ -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
View File
@@ -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;
+1 -4
View File
@@ -80,7 +80,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// Pre-hop drop handling (compile-time flag).
#ifndef MESHTASTIC_PREHOP_DROP
#define MESHTASTIC_PREHOP_DROP 1
#define MESHTASTIC_PREHOP_DROP 0
#endif
/// Convert a preprocessor name into a quoted string
@@ -162,9 +162,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.
+10 -25
View File
@@ -415,45 +415,30 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
#if !defined(M5STACK_UNITC6L)
case INA_ADDR: // Same as SHT2X
case INA_ADDR_ALTERNATE:
case INA_ADDR_WAVESHARE_UPS: {
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)) {
} else if (detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
type = SHTXX;
}
#endif
else { // Assume INA219 if none of the above ones are found
} else { // Assume INA219 if none of the above ones are found
logFoundDevice("INA219", (uint8_t)addr.address);
type = INA219;
}
break;
}
case INA3221_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue);
+6 -11
View File
@@ -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
+70 -76
View File
@@ -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,7 +54,6 @@ 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"
@@ -71,6 +69,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
@@ -105,27 +109,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
@@ -244,7 +227,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 +249,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 +273,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 +296,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)
@@ -405,6 +388,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 +474,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 +527,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 +548,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 +592,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 +663,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 +718,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 +757,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 +832,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 +903,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,7 +996,7 @@ int32_t Screen::runOnce()
// this must be before the frameState == FIXED check, because we always
// want to draw at least one FIXED frame before doing forceDisplay
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
@@ -1055,7 +1058,7 @@ void Screen::setSSLFrames()
// LOG_DEBUG("Show SSL frames");
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
ui->setFrames(sslFrames, 1);
updateUiFrame(ui);
ui->update();
}
}
@@ -1091,7 +1094,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)
@@ -1466,15 +1469,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 +1605,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 +1816,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 +1831,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;
+60 -260
View File
@@ -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 -3
View File
@@ -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);
-819
View File
@@ -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 &region = colorRegions[colorRegionCount++];
region.x = x;
region.y = y;
region.width = width;
region.height = height;
region.onColorBe = onColorBe;
region.offColorBe = offColorBe;
region.enabled = true;
// Keep one disabled sentinel after the active range for ST7789 countColorRegions().
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
colorRegions[colorRegionCount].enabled = false;
}
colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false;
}
// Current working role colors (big-endian). Initialised to Dark defaults;
// call loadThemeDefaults() after boot / theme change to refresh.
static TFTRoleColorsBe roleColors[static_cast<size_t>(TFTColorRole::Count)] = {
{toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground
{toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle
{toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars
{toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle
{toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight
{toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar
{toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow
};
} // namespace
// Theme accessors
const TFTThemeDef &getActiveTheme()
{
return kThemes[resolveThemeIndex()];
}
// Visible-theme accessors
// These iterate only themes flagged .visible = true, preserving kThemes[]
// order. Menu code should use these so hidden themes don't appear in the
// picker while still applying correctly if their ID is persisted.
size_t getVisibleThemeCount()
{
size_t count = 0;
for (size_t i = 0; i < kInternalThemeCount; i++) {
if (kThemes[i].visible)
count++;
}
return count;
}
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex)
{
size_t seen = 0;
for (size_t i = 0; i < kInternalThemeCount; i++) {
if (!kThemes[i].visible)
continue;
if (seen == visibleIndex)
return kThemes[i];
seen++;
}
// Fallback: return first theme (never trust a bad index).
return kThemes[0];
}
size_t getActiveVisibleThemeIndex()
{
const size_t active = resolveThemeIndex();
if (!kThemes[active].visible)
return SIZE_MAX;
size_t visibleIdx = 0;
for (size_t i = 0; i < active; i++) {
if (kThemes[i].visible)
visibleIdx++;
}
return visibleIdx;
}
uint16_t getThemeHeaderBg()
{
#if GRAPHICS_TFT_COLORING_ENABLED
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
return TFT_HEADER_BG_COLOR_OVERRIDE;
#else
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderBackground)].onColor;
#endif
#else
return TFTPalette::DarkGray;
#endif
}
uint16_t getThemeHeaderText()
{
#if GRAPHICS_TFT_COLORING_ENABLED
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
return TFT_HEADER_TITLE_COLOR_OVERRIDE;
#else
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderTitle)].offColor;
#endif
#else
return TFTPalette::White;
#endif
}
uint16_t getThemeHeaderStatus()
{
#if GRAPHICS_TFT_COLORING_ENABLED
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
return TFT_HEADER_STATUS_COLOR_OVERRIDE;
#else
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderStatus)].offColor;
#endif
#else
return TFTPalette::White;
#endif
}
uint16_t getThemeBodyBg()
{
#if GRAPHICS_TFT_COLORING_ENABLED
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].onColor;
#else
return TFTPalette::Black;
#endif
}
uint16_t getThemeBodyFg()
{
#if GRAPHICS_TFT_COLORING_ENABLED
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].offColor;
#else
return TFTPalette::White;
#endif
}
bool isThemeFullFrameInvert()
{
#if GRAPHICS_TFT_COLORING_ENABLED
return kThemes[resolveThemeIndex()].fullFrameInvert;
#else
return false;
#endif
}
uint16_t getThemeBatteryFillColor(int batteryPercent)
{
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
if (batteryPercent <= 20) {
return theme.batteryFillBad;
}
if (batteryPercent <= 50) {
return theme.batteryFillMedium;
}
return theme.batteryFillGood;
}
void loadThemeDefaults()
{
#if GRAPHICS_TFT_COLORING_ENABLED
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
for (uint8_t i = 0; i < static_cast<uint8_t>(TFTColorRole::Count); i++) {
roleColors[i].onColorBe = toBe565(theme.roles[i].onColor);
roleColors[i].offColorBe = toBe565(theme.roles[i].offColor);
}
#endif
}
// Role color assignment with theme-aware transforms
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return;
#endif
const uint8_t index = static_cast<uint8_t>(role);
if (index >= static_cast<uint8_t>(TFTColorRole::Count)) {
return;
}
const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F;
const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight);
const bool isBodyRole = !isHighlightRole && isBodyColorRole(role);
// Classic monochrome themes collapse all non-black accents into one tone.
if (isMonochromeTheme(themeId)) {
if (onColor != TFTPalette::Black) {
onColor = getMonochromeAccent(themeId);
}
} else {
switch (themeId) {
case ThemeID::DefaultLight:
if (isHighlightRole) {
// High-contrast highlight chips on light UI.
onColor = TFTPalette::Black;
offColor = TFTPalette::Yellow;
} else if (isBodyRole) {
// Invert body colors for readability on white frames.
if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) {
offColor = TFTPalette::White;
}
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
}
break;
case ThemeID::Christmas:
if (isHighlightRole || isBodyRole) {
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold);
replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine);
}
break;
case ThemeID::Pink:
if (isHighlightRole) {
onColor = TFTPalette::Black;
offColor = TFTPalette::HotPink;
} else if (isBodyRole) {
replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink);
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink);
}
break;
case ThemeID::Creamsicle:
if (isHighlightRole) {
onColor = TFTPalette::Black;
offColor = TFTPalette::CreamOrange;
} else if (isBodyRole) {
replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream);
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange);
}
break;
case ThemeID::Blue:
if (isHighlightRole || isBodyRole) {
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue);
replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy);
}
break;
default:
break;
}
}
roleColors[index].onColorBe = toBe565(onColor);
roleColors[index].offColorBe = toBe565(offColor);
}
// Region registration
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return;
#endif
const uint8_t roleIndex = static_cast<uint8_t>(role);
if (roleIndex >= static_cast<uint8_t>(TFTColorRole::Count)) {
return;
}
if (!normalizeRegion(x, y, width, height)) {
return;
}
const TFTRoleColorsBe &colors = roleColors[roleIndex];
appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe);
}
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
int16_t height)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
(void)role;
(void)onColor;
(void)offColor;
(void)x;
(void)y;
(void)width;
(void)height;
return;
#else
setTFTColorRole(role, onColor, offColor);
registerTFTColorRegion(role, x, y, width, height);
#endif
}
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return;
#endif
if (!normalizeRegion(x, y, width, height))
return;
appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor));
}
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
(void)boxLeft;
(void)boxTop;
(void)boxWidth;
(void)boxHeight;
return;
#else
// Use theme-appropriate menu colors.
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
const TFTThemeRoleColor &menuBody = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBody)];
const TFTThemeRoleColor &menuBorder = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBorder)];
// Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly.
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1,
boxWidth + 2, boxHeight + 2);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight);
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth,
1);
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1);
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight);
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight);
#endif
}
// Frame signature & utilities
uint32_t getTFTColorFrameSignature()
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return 0;
#else
uint32_t hash = kFnv1aOffsetBasis;
hash = fnv1aAppendByte(hash, colorRegionCount);
for (uint8_t i = 0; i < colorRegionCount; i++) {
const TFTColorRegion &r = colorRegions[i];
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.x));
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.y));
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.width));
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.height));
hash = fnv1aAppendU16(hash, r.onColorBe);
hash = fnv1aAppendU16(hash, r.offColorBe);
}
return hash;
#endif
}
uint8_t getTFTColorRegionCount()
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return 0;
#else
return colorRegionCount;
#endif
}
void clearTFTColorRegions()
{
for (uint8_t i = 0; i < colorRegionCount; i++) {
colorRegions[i].enabled = false;
}
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
colorRegions[colorRegionCount].enabled = false;
}
colorRegionCount = 0;
}
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
{
for (int i = static_cast<int>(colorRegionCount) - 1; i >= 0; i--) {
const TFTColorRegion &r = colorRegions[i];
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
return isset ? r.onColorBe : r.offColorBe;
}
}
return isset ? defaultOnColor : defaultOffColor;
}
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
(void)x;
(void)y;
return defaultOffColor;
#else
const uint16_t defaultOffBe = toBe565(defaultOffColor);
const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe);
return static_cast<uint16_t>((sampledBe >> 8) | (sampledBe << 8));
#endif
}
} // namespace graphics
-163
View File
@@ -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
+50 -160
View File
@@ -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,51 +1253,43 @@ 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 (isset != dblbuf_isset) {
break;
}
} else if (isset) {
break;
}
x_LastChanged--;
}
if (x_FirstPixelUpdate < displayWidth) {
// Align the first pixel for update to an even number so the total alignment of
// the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
x_FirstPixelUpdate = x_FirstChanged & ~1U;
x_LastPixelUpdate = x_LastChanged | 1U;
x_FirstPixelUpdate &= ~1;
// Step 3a: copy rest of the pixels in this row into the pixel line buffer,
// while also recording the last pixel in the row that needs updating.
// Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set.
for (x = x_FirstPixelUpdate; x < displayWidth; x++) {
isset = buffer[x + y_byteIndex] & y_byteMask;
linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack;
if (!fromBlank) {
dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask;
if (isset != dblbuf_isset) {
x_LastPixelUpdate = x;
}
} else if (isset) {
x_LastPixelUpdate = x;
}
}
// Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs.
// Most displays will have even number of pixels in a row -- this will be in bounds
// of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.)
x_LastPixelUpdate |= 1;
// Ensure the last pixel index does not exceed the display width.
if (x_LastPixelUpdate >= displayWidth) {
x_LastPixelUpdate = displayWidth - 1;
}
// Step 3: Copy only the changed span into the pixel line buffer.
for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
isset = buffer[x + y_byteIndex] & y_byteMask;
#if GRAPHICS_TFT_COLORING_ENABLED
if (hasColorRegions) {
linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
colorTftWhite, colorTftBlack);
} else {
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
}
#else
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
#endif
}
#if defined(HACKADAY_COMMUNICATOR)
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
(x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1);
#else
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
tft->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 +1298,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 +1511,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 +1521,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;
}
+1 -2
View File
@@ -63,5 +63,4 @@ class TFTDisplay : public OLEDDisplay
virtual bool connect() override;
uint16_t *linePixelBuffer = nullptr;
uint16_t *repaintChunkBuffer = nullptr;
};
};
-70
View File
@@ -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
+3 -7
View File
@@ -145,7 +145,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
// === Set Title, Blank for Clock
const char *titleStr = "";
// === Header ===
graphics::drawCommonHeader(display, x, y, titleStr, true, true, 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
+89 -37
View File
@@ -9,61 +9,113 @@ namespace graphics
{
namespace CompassRenderer
{
// Point helper class for compass calculations
struct Point {
float x, y;
Point(float x, float y) : x(x), y(y) {}
void rotate(float angle)
{
float cos_a = cosf(angle);
float sin_a = sinf(angle);
float new_x = x * cos_a - y * sin_a;
float new_y = x * sin_a + y * cos_a;
x = new_x;
y = new_y;
}
void scale(float factor)
{
x *= factor;
y *= factor;
}
void translate(float dx, float dy)
{
x += dx;
y += dy;
}
};
void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
{
// Show the compass heading (not implemented in original)
// This could draw a "N" indicator or north arrow
// For now, we'll draw a simple north indicator
// const float radius = 17.0f;
if (currentResolution == ScreenResolution::High) {
radius += 4;
}
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));
float northX = 0.0f;
float northY = -radius;
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) {
const float c = cosf(-myHeading);
const float s = sinf(-myHeading);
const float rx = northX * c - northY * s;
const float ry = northX * s + northY * c;
northX = rx;
northY = ry;
}
northX += compassX;
northY += compassY;
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(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
} else {
display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
display->fillRect(northX - 4, northY - 1, nLabelWidth + 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(northX, northY - 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);
Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
for (int i = 0; i < 4; i++) {
arrowPoints[i]->rotate(headingRadian);
arrowPoints[i]->scale(compassDiam * 0.6);
arrowPoints[i]->translate(compassX, compassY);
}
#ifdef USE_EINK
display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
#else
display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
#endif
display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
}
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
{
float radians = bearing * DEG_TO_RAD;
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);
}
bool getHeadingRadians(double lat, double lon, float &headingRadian)
-1
View File
@@ -1,7 +1,6 @@
#pragma once
#include "graphics/Screen.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
+5 -30
View File
@@ -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"
@@ -471,11 +469,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 +479,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 +492,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 +511,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 +584,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
+122 -63
View File
@@ -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"
@@ -31,6 +30,8 @@
#include <functional>
#include <utility>
extern uint16_t TFT_MESH;
namespace graphics
{
@@ -2027,6 +2028,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 +2318,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 +2329,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 +2345,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 +2354,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 +2366,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 +2649,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 +2724,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MuiPicker:
switchToMUIMenu();
break;
case TftColorMenuPicker:
TFTColorPickerMenu(display);
break;
case BrightnessPicker:
BrightnessPickerMenu();
break;
@@ -2737,9 +2799,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MessageBubblesMenu:
messageBubblesMenu();
break;
case ThemeMenu:
themeMenu();
break;
}
menuQueue = MenuNone;
}
@@ -2751,4 +2810,4 @@ void menuHandler::saveUIConfig()
} // namespace graphics
#endif
#endif
+16 -3
View File
@@ -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>;
+12 -132
View File
@@ -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;
-57
View File
@@ -11,8 +11,6 @@
#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>
@@ -215,33 +213,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 +227,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];
@@ -318,9 +286,6 @@ void drawEntryHopSignal(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;
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -350,19 +315,6 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
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);
@@ -398,9 +350,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] = "";
@@ -506,9 +455,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);
@@ -764,9 +710,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);
+1 -35
View File
@@ -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);
File diff suppressed because it is too large Load Diff
-2
View File
@@ -54,8 +54,6 @@ class UIRenderer
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
// Icon and screen drawing functions
static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+9 -142
View File
@@ -25,116 +25,6 @@
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
@@ -144,48 +34,24 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_
(void)pin_busy;
(void)pin_rst;
SafeFastEPD *safeEpaper = new SafeFastEPD;
epaper = safeEpaper;
epaper = new FASTEPD;
int initRc = BBEP_ERROR_BAD_PARAMETER;
#if defined(T5_S3_EPAPER_PRO_V1)
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
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);
}
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true); // Blocking initial clear
}
void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
@@ -245,8 +111,9 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
epaper->fullUpdate(CLEAR_SLOW, false);
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
} else {
// FAST: true partial update - compares pCurrent vs pPrevious and only applies
// update waveform to rows that changed. partialUpdate() updates pPrevious.
// FAST: true partial update — compares pCurrent vs pPrevious and only applies
// the update waveform to rows that actually changed. Unchanged rows get a neutral
// signal (no visible effect). partialUpdate() updates pPrevious internally.
epaper->partialUpdate(false, 0, dstTotalRows - 1);
}
}
-9
View File
@@ -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 |
@@ -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
@@ -423,14 +333,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 +422,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 +527,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 +893,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 +1109,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 +1118,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 +1386,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 +1422,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 +1449,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 +1459,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 +1468,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 +1499,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 +1566,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 +1595,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 +1727,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 +1734,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
+148 -272
View File
@@ -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
+7 -16
View File
@@ -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
+1 -121
View File
@@ -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
-17
View File
@@ -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
+1 -1
View File
@@ -142,4 +142,4 @@ class Persistence
} // namespace NicheGraphics::InkHUD
#endif
#endif
+2 -111
View File
@@ -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
+1 -4
View File
@@ -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
-3
View File
@@ -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 -4
View File
@@ -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 -2
View File
@@ -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"
+6 -51
View File
@@ -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;
}
-3
View File
@@ -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;
+1 -32
View File
@@ -3,12 +3,6 @@
#include "PowerFSM.h"
#include "configuration.h"
#include "modules/ExternalNotificationModule.h"
#include <cstring>
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#endif
#if ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
@@ -45,31 +39,6 @@ bool TouchScreenImpl1::getTouch(int16_t &x, int16_t &y)
return _getTouch(&x, &y);
}
bool TouchScreenImpl1::fastTapModeEnabled() const
{
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
const auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance();
if (!inkhud) {
return false;
}
for (auto *sa : inkhud->systemApplets) {
if (!sa || !sa->name) {
continue;
}
if (strcmp(sa->name, "Keyboard") == 0) {
return sa->isForeground();
}
}
#endif
return false;
}
bool TouchScreenImpl1::longPressEnabled() const
{
return !fastTapModeEnabled();
}
/**
* @brief forward touchscreen event
*
@@ -114,4 +83,4 @@ void TouchScreenImpl1::onEvent(const TouchEvent &event)
return;
}
this->notifyObservers(&e);
}
}
-2
View File
@@ -10,8 +10,6 @@ class TouchScreenImpl1 : public TouchScreenBase
protected:
virtual bool getTouch(int16_t &x, int16_t &y);
virtual void onEvent(const TouchEvent &event);
bool fastTapModeEnabled() const override;
bool longPressEnabled() const override;
bool (*_getTouch)(int16_t *, int16_t *);
};
+5 -8
View File
@@ -951,12 +951,8 @@ void setup()
#endif
#endif
std::unique_ptr<RadioInterface> rIf;
if (!config.lora.serial_hal_only) {
rIf = initLoRa();
} else {
LOG_INFO("skipping LoRa radio init, for serialHal");
}
auto rIf = initLoRa();
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
#if !MESHTASTIC_EXCLUDE_MQTT
@@ -1000,9 +996,10 @@ void setup()
// Start airtime logger thread.
airTime = new AirTime();
if (!rIf && !config.lora.serial_hal_only)
if (!rIf)
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO);
else if (rIf) {
else {
// Log bit rate to debug output
LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) /
(float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) *
+8 -18
View File
@@ -2,21 +2,18 @@
#include "meshUtils.h"
// Convert seconds to ms, clamping at INT32_MAX (~24.86 days)
static inline uint32_t secondsToMsClamped(uint32_t secs)
{
constexpr uint32_t MAX_MS = static_cast<uint32_t>(INT32_MAX);
return (secs > MAX_MS / 1000U) ? MAX_MS : secs * 1000U;
}
uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval, uint32_t defaultInterval)
{
return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : defaultInterval);
if (configuredInterval > 0)
return configuredInterval * 1000;
return defaultInterval * 1000;
}
uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval)
{
return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : default_broadcast_interval_secs);
if (configuredInterval > 0)
return configuredInterval * 1000;
return default_broadcast_interval_secs * 1000;
}
uint32_t Default::getConfiguredOrDefault(uint32_t configured, uint32_t defaultValue)
@@ -50,14 +47,7 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d
meshtastic_Config_DeviceConfig_Role_TAK_TRACKER))
return getConfiguredOrDefaultMs(configured, defaultValue);
// Saturate at INT32_MAX to match secondsToMsClamped: float→uint32_t when
// out of range is UB, and the result is consumed as an int32_t downstream.
constexpr uint32_t MAX_MS = static_cast<uint32_t>(INT32_MAX);
uint32_t base = getConfiguredOrDefaultMs(configured, defaultValue);
float coef = congestionScalingCoefficient(numOnlineNodes);
if (static_cast<double>(base) * static_cast<double>(coef) >= static_cast<double>(MAX_MS))
return MAX_MS;
return base * coef;
return getConfiguredOrDefaultMs(configured, defaultValue) * congestionScalingCoefficient(numOnlineNodes);
}
uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue)
@@ -76,4 +66,4 @@ uint8_t Default::getConfiguredOrDefaultHopLimit(uint8_t configured)
#else
return (configured >= HOP_MAX) ? HOP_MAX : config.lora.hop_limit;
#endif
}
}
+3 -16
View File
@@ -19,12 +19,8 @@ extern Adafruit_nRFCrypto nRFCrypto;
#include <Arduino.h>
#elif defined(ARCH_PORTDUINO)
#include <random>
#include <sys/random.h>
#include <unistd.h>
#ifdef __linux__
#include <sys/random.h> // getrandom()
#else
#include <stdlib.h> // arc4random_buf() on Darwin/BSD
#endif
#endif
namespace HardwareRNG
@@ -52,11 +48,8 @@ bool mixWithLoRaEntropy(uint8_t *buffer, size_t length)
// and return false so callers know no extra mixing occurred.
RadioLibInterface *radio = RadioLibInterface::instance;
if (!radio) {
// This path can run during portduinoSetup() before the console is initialized,
// both for unit-test binaries and the simulator's meshtasticd; LOG_* dereferences `console`.
if (console) {
LOG_ERROR("No radio instance available to provide entropy");
}
// Intentionally silent: this path runs during portduinoSetup() before the
// console/SerialConsole is initialized, so LOG_* here would dereference a null pointer.
return false;
}
@@ -123,16 +116,10 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy)
filled = true;
#elif defined(ARCH_PORTDUINO)
// Prefer the host OS RNG first when running under Portduino.
#ifdef __linux__
ssize_t generated = ::getrandom(buffer, length, 0);
if (generated == static_cast<ssize_t>(length)) {
filled = true;
}
#else
// arc4random_buf is available on Darwin/BSD and cannot fail.
::arc4random_buf(buffer, length);
filled = true;
#endif
if (!filled) {
fillWithRandomDevice(buffer, length);
+2 -5
View File
@@ -6,11 +6,8 @@
#include "configuration.h"
#include "detect/LoRaRadioType.h"
// Sentinel marking the end of a modem preset array. Declared `const` rather
// than `constexpr` because the cast from 0xFF to the enum is out-of-range and
// therefore not a valid constant expression on Clang 16+ (Apple Clang on
// macOS). The value is only ever compared at runtime, so static-init is fine.
static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END =
// Sentinel marking the end of a modem preset array
static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END =
static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(0xFF);
// Region profile: bundles the preset list with regulatory parameters shared across regions
+6 -4
View File
@@ -688,7 +688,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32);
#if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \
defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)) && \
defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT)) && \
HAS_TFT
// switch BT off by default; use TFT programming mode or hotkey to enable
config.bluetooth.enabled = false;
@@ -1205,11 +1205,11 @@ void NodeDB::loadFromDisk()
spiLock->unlock();
#endif
#ifdef FSCom
#if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO)
#ifdef FACTORY_INSTALL
spiLock->lock();
if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) {
LOG_WARN("Factory Install Reset!");
rmDir("/prefs");
FSCom.format();
FSCom.mkdir("/prefs");
File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE);
if (f2) {
@@ -1611,10 +1611,12 @@ bool NodeDB::saveToDisk(int saveWhat)
if (!success) {
LOG_ERROR("Failed to save to disk, retrying");
#ifdef ARCH_NRF52 // @geeksville is not ready yet to say we should do this on other platforms. See bug #4184 discussion
spiLock->lock();
fsFormat();
FSCom.format();
spiLock->unlock();
#endif
success = saveToDiskNoRetry(saveWhat);
RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE
+6 -7
View File
@@ -522,6 +522,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
// LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard,
// nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name);
// Occasional progress logging. (readIndex==2 will be true for the first non-us node)
if (readIndex == 2 || readIndex % 20 == 0) {
LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes());
}
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag;
fromRadioScratch.node_info = infoToSend;
prefetchNodeInfos();
@@ -652,11 +657,9 @@ void PhoneAPI::releaseQueueStatusPhonePacket()
void PhoneAPI::prefetchNodeInfos()
{
bool added = false;
bool wasEmpty = false;
// Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment.
{
concurrency::LockGuard guard(&nodeInfoMutex);
wasEmpty = nodeInfoQueue.empty();
while (nodeInfoQueue.size() < kNodePrefetchDepth) {
auto nextNode = nodeDB->readNextMeshNode(readIndex);
if (!nextNode)
@@ -670,15 +673,11 @@ void PhoneAPI::prefetchNodeInfos()
info.via_mqtt = isUs ? false : info.via_mqtt;
info.is_favorite = info.is_favorite || isUs;
nodeInfoQueue.push_back(info);
// Log progress here (at fetch time) so readIndex is accurate and each value logs only once.
if (readIndex == 2 || readIndex % 20 == 0) {
LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes());
}
added = true;
}
}
if (added && wasEmpty)
if (added)
onNowHasData(0);
}
-4
View File
@@ -25,7 +25,6 @@
#ifdef ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
#include "platform/portduino/SerialHal.h"
#include "platform/portduino/SimRadio.h"
#include "platform/portduino/USBHal.h"
#endif
@@ -293,9 +292,6 @@ std::unique_ptr<RadioInterface> initLoRa()
portduino_config.lora_spi_dev.c_str());
if (portduino_config.lora_spi_dev == "ch341") {
RadioLibHAL = ch341Hal;
} else if (portduino_config.lora_spi_dev == "serial") {
RadioLibHAL = new SerialHal(portduino_config.lora_serial_device, portduino_config.lora_serial_baud,
(uint32_t)portduino_config.lora_serial_timeout_ms);
} else {
if (RadioLibHAL != nullptr) {
delete RadioLibHAL;
+1 -1
View File
@@ -119,7 +119,7 @@ bool RadioLibInterface::canSendImmediately()
return true;
}
bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag)
bool RadioLibInterface::receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag)
{
bool detected = (irq & (syncWordHeaderValidFlag | preambleDetectedFlag));
// Handle false detections
+1 -1
View File
@@ -220,7 +220,7 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified
protected:
uint32_t activeReceiveStart = 0;
bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag);
bool receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag);
/** Do any hardware setup needed on entry into send configuration for the radio.
* Subclasses can customize, but must also call this base method */
+4 -7
View File
@@ -736,13 +736,9 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
// Also, we should set the time from the ISR and it should have msec level resolution
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
// Store a copy of the encrypted packet for MQTT.
// Local, not a class member: handleReceived re-enters itself when a module
// reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal,
// and a member would be silently overwritten without release on the inner
// call. Each invocation now owns its own copy (issue #9632, #10101, #8729).
// Store a copy of encrypted packet for MQTT
DEBUG_HEAP_BEFORE;
meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p);
p_encrypted = packetPool.allocCopy(*p);
DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted);
// Take those raw bytes and convert them back into a well structured protobuf we can understand
@@ -836,7 +832,8 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
#endif
}
packetPool.release(p_encrypted); // Release the encrypted packet (release() handles nullptr)
packetPool.release(p_encrypted); // Release the encrypted packet
p_encrypted = nullptr;
}
void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
+3
View File
@@ -92,6 +92,9 @@ class Router : protected concurrency::OSThread, protected PacketHistory
before us */
uint32_t rxDupe = 0, txRelayCanceled = 0;
// pointer to the encrypted packet
meshtastic_MeshPacket *p_encrypted = nullptr;
protected:
friend class RoutingModule;
-385
View File
@@ -1,385 +0,0 @@
#include "mesh/SerialHalDevice.h"
#include "NodeDB.h"
#include "SPILock.h"
#include "concurrency/Periodic.h"
#include "configuration.h"
#include "mesh/StreamAPI.h"
#include "mesh/generated/meshtastic/config.pb.h"
#include <Arduino.h>
#include <SPI.h>
#include <cstring>
#include <stdint.h>
#if defined(ARCH_ESP32)
#if defined(HW_SPI1_DEVICE)
extern SPIClass SPI1;
#endif
#endif
namespace
{
constexpr uint32_t SERIAL_PI_RISING = 1;
constexpr uint32_t SERIAL_PI_FALLING = 2;
constexpr uint32_t SERIAL_PI_INPUT = 0;
constexpr uint32_t SERIAL_PI_OUTPUT = 1;
constexpr size_t MAX_INTERRUPT_SLOTS = 8;
constexpr int32_t INTERRUPT_POLL_MS = 5;
struct InterruptSlot {
bool used = false;
uint32_t pin = 0;
uint32_t mode = 0;
volatile bool pending = false;
};
concurrency::Lock interruptMutex;
InterruptSlot interruptSlots[MAX_INTERRUPT_SLOTS];
StreamAPI *interruptStreamApi = nullptr;
concurrency::Periodic *interruptEmitter = nullptr;
int findSlotByPinLocked(uint32_t pin)
{
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
if (interruptSlots[i].used && interruptSlots[i].pin == pin) {
return (int)i;
}
}
return -1;
}
int allocateSlotLocked()
{
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
if (!interruptSlots[i].used) {
return (int)i;
}
}
return -1;
}
#if defined(ARCH_PORTDUINO) || defined(ARCH_RP2040)
PinStatus toInterruptMode(uint32_t serialMode)
{
if (serialMode == SERIAL_PI_RISING) {
return PinStatus::RISING;
}
if (serialMode == SERIAL_PI_FALLING) {
return PinStatus::FALLING;
}
return PinStatus::CHANGE;
}
#else
int toInterruptMode(uint32_t serialMode)
{
if (serialMode == SERIAL_PI_RISING) {
return RISING;
}
if (serialMode == SERIAL_PI_FALLING) {
return FALLING;
}
return CHANGE;
}
#endif
int32_t pumpInterruptEvents();
void ensureInterruptEmitter()
{
if (!interruptEmitter) {
interruptEmitter = new concurrency::Periodic("SerialHalIrqEmitter", pumpInterruptEvents);
}
}
void emitInterruptEvent(uint32_t pin, StreamAPI *streamApi)
{
if (streamApi == nullptr) {
return;
}
meshtastic_SerialHalResponse event = meshtastic_SerialHalResponse_init_zero;
event.transaction_id = 0; // asynchronous interrupt notification
event.result = meshtastic_SerialHalResponse_Result_OK;
event.value = pin; // host-side SerialHal treats value as interrupt pin
SerialHalDevice::emitResponse(event, streamApi);
}
void markPendingBySlot(uint8_t slot)
{
if (slot < MAX_INTERRUPT_SLOTS && interruptSlots[slot].used) {
interruptSlots[slot].pending = true;
}
}
void isr0()
{
markPendingBySlot(0);
}
void isr1()
{
markPendingBySlot(1);
}
void isr2()
{
markPendingBySlot(2);
}
void isr3()
{
markPendingBySlot(3);
}
void isr4()
{
markPendingBySlot(4);
}
void isr5()
{
markPendingBySlot(5);
}
void isr6()
{
markPendingBySlot(6);
}
void isr7()
{
markPendingBySlot(7);
}
void (*const isrTable[MAX_INTERRUPT_SLOTS])() = {isr0, isr1, isr2, isr3, isr4, isr5, isr6, isr7};
int32_t pumpInterruptEvents()
{
uint32_t toEmit[MAX_INTERRUPT_SLOTS] = {0};
size_t emitCount = 0;
StreamAPI *streamApi = nullptr;
{
concurrency::LockGuard lock(&interruptMutex);
streamApi = interruptStreamApi;
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
if (interruptSlots[i].used && interruptSlots[i].pending) {
interruptSlots[i].pending = false;
toEmit[emitCount++] = interruptSlots[i].pin;
}
}
}
for (size_t i = 0; i < emitCount; ++i) {
emitInterruptEvent(toEmit[i], streamApi);
}
return INTERRUPT_POLL_MS;
}
} // namespace
// Helper to safely set response result
static inline void setResponseError(meshtastic_SerialHalResponse &response, meshtastic_SerialHalResponse_Result result,
const char *error = nullptr)
{
response.result = result;
if (error != nullptr) {
snprintf(response.error, sizeof(response.error), "%s", error);
}
}
void SerialHalDevice::handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi)
{
if (buf == nullptr || streamApi == nullptr) {
return;
}
// Validate role - SerialHal commands only handled when config.lora.serial_hal_only
if (!config.lora.serial_hal_only) {
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
snprintf(response.error, sizeof(response.error), "SerialHal not enabled for this role");
emitResponse(response, streamApi);
return;
}
// Decode the command
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
if (!pb_decode_from_bytes(buf, len, &meshtastic_SerialHalCommand_msg, &cmd)) {
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
response.result = meshtastic_SerialHalResponse_Result_BAD_REQUEST;
snprintf(response.error, sizeof(response.error), "Failed to decode SerialHalCommand");
emitResponse(response, streamApi);
return;
}
// Initialize response with matching transaction_id
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
response.transaction_id = cmd.transaction_id;
response.result = meshtastic_SerialHalResponse_Result_OK;
// Dispatch to operation handler
switch (cmd.type) {
case meshtastic_SerialHalCommand_Type_PIN_MODE:
handlePinMode(cmd, response);
break;
case meshtastic_SerialHalCommand_Type_DIGITAL_WRITE:
handleDigitalWrite(cmd, response);
break;
case meshtastic_SerialHalCommand_Type_DIGITAL_READ:
handleDigitalRead(cmd, response);
break;
case meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT:
handleAttachInterrupt(cmd, response);
break;
case meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT:
handleDetachInterrupt(cmd, response);
break;
case meshtastic_SerialHalCommand_Type_SPI_TRANSFER:
handleSpiTransfer(cmd, response);
break;
case meshtastic_SerialHalCommand_Type_NOOP:
// NOOP: just return OK
break;
default:
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
snprintf(response.error, sizeof(response.error), "Unknown SerialHal operation type");
break;
}
emitResponse(response, streamApi);
}
void SerialHalDevice::handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
{
// LOG_DEBUG("SerialHalDevice: pinMode pin=%u mode=%u", cmd.pin, cmd.mode);
if (cmd.mode == SERIAL_PI_INPUT) {
pinMode((int)cmd.pin, INPUT);
} else if (cmd.mode == SERIAL_PI_OUTPUT) {
pinMode((int)cmd.pin, OUTPUT);
} else {
setResponseError(response, meshtastic_SerialHalResponse_Result_BAD_REQUEST, "Unsupported pin mode");
}
}
void SerialHalDevice::handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
{
// LOG_DEBUG("SerialHalDevice: digitalWrite pin=%u value=%u", cmd.pin, cmd.value);
digitalWrite((int)cmd.pin, cmd.value ? HIGH : LOW);
}
void SerialHalDevice::handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
{
// LOG_DEBUG("SerialHalDevice: digitalRead pin=%u", cmd.pin);
response.value = (uint32_t)digitalRead((int)cmd.pin);
}
void SerialHalDevice::handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
{
// LOG_DEBUG("SerialHalDevice: attachInterrupt pin=%u mode=%u", cmd.pin, cmd.mode);
ensureInterruptEmitter();
int slot = -1;
{
concurrency::LockGuard lock(&interruptMutex);
slot = findSlotByPinLocked(cmd.pin);
if (slot < 0) {
slot = allocateSlotLocked();
}
if (slot >= 0) {
interruptSlots[slot].used = true;
interruptSlots[slot].pin = cmd.pin;
interruptSlots[slot].mode = cmd.mode;
interruptSlots[slot].pending = false;
}
}
if (slot < 0) {
setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "No interrupt slots available");
return;
}
::attachInterrupt((int)cmd.pin, isrTable[slot], toInterruptMode(cmd.mode));
}
void SerialHalDevice::handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
{
// LOG_DEBUG("SerialHalDevice: detachInterrupt pin=%u", cmd.pin);
::detachInterrupt((int)cmd.pin);
{
concurrency::LockGuard lock(&interruptMutex);
const int slot = findSlotByPinLocked(cmd.pin);
if (slot >= 0) {
interruptSlots[slot] = InterruptSlot{};
}
}
}
void SerialHalDevice::handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
{
if (cmd.data.size == 0) {
return;
}
#if !ARCH_PORTDUINO
if (spiLock == nullptr) {
setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "SPI lock not initialized");
return;
}
#if defined(HW_SPI1_DEVICE)
SPIClass &spiBus = SPI1;
#else
SPIClass &spiBus = SPI;
#endif
response.data.size = cmd.data.size;
{
concurrency::LockGuard guard(spiLock);
spiBus.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
#ifdef ARCH_ESP32
spiBus.transferBytes(cmd.data.bytes, response.data.bytes, cmd.data.size);
#else
spiBus.transfer(cmd.data.bytes, response.data.bytes, cmd.data.size);
#endif
spiBus.endTransaction();
}
#else
// SPI wiring is board/radio-specific; keep this explicit for now.
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
snprintf(response.error, sizeof(response.error), "SPI not supported on this platform");
#endif
}
void SerialHalDevice::emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi)
{
if (streamApi == nullptr) {
return;
}
// Encode the response
uint8_t encoded[meshtastic_SerialHalResponse_size] = {0};
const size_t responseLen =
pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_SerialHalResponse_msg, static_cast<const void *>(&response));
if (responseLen == 0 || responseLen > 0xFFFF) {
LOG_ERROR("SerialHalDevice: Failed to encode response (len=%zu)", responseLen);
return;
}
// Build frame with StreamAPI framing: START1 SERIALHAL_MAGIC LEN_H LEN_L [payload]
constexpr uint8_t START1 = 0x94;
constexpr uint8_t SERIALHAL_MAGIC = 0xA5;
uint8_t hdr[4];
hdr[0] = START1;
hdr[1] = SERIALHAL_MAGIC;
hdr[2] = (uint8_t)((responseLen >> 8) & 0xFF); // LEN_H
hdr[3] = (uint8_t)(responseLen & 0xFF); // LEN_L
// Emit via StreamAPI (this uses the internal txBuf + framing)
streamApi->emitSerialHalResponse(hdr, sizeof(hdr), encoded, responseLen);
// Keep a recent stream instance so async interrupt events can be emitted.
{
concurrency::LockGuard lock(&interruptMutex);
interruptStreamApi = streamApi;
}
}
-90
View File
@@ -1,90 +0,0 @@
#pragma once
#include "mesh/generated/meshtastic/serial_hal.pb.h"
#include <cstdint>
/**
* @brief Device-side handler for SerialHal GPIO/SPI operations over StreamAPI framing.
*
* This module decodes SerialHalCommand protobufs received from a host and executes
* the requested GPIO (pinMode, digitalWrite, digitalRead, attach/detachInterrupt) or
* SPI operations, then returns results via SerialHalResponse.
*
* Usage:
* 1. Override StreamAPI::handleSerialHalCommand() in a subclass
* 2. Call SerialHalDevice::handleCommand(buf, len, streamApi)
* 3. SerialHalDevice will decode, execute, and emit the response
*
* The handler is only active when config.lora.serial_hal_only is true.
*/
class StreamAPI; // forward declaration
class SerialHalDevice
{
public:
/**
* @brief Process a SerialHalCommand and emit a response.
*
* Decodes the protobuf, validates the operation, executes it on the device,
* and writes the response back via the StreamAPI instance.
*
* @param buf Pointer to the encoded SerialHalCommand protobuf payload (not including framing)
* @param len Length of the encoded payload
* @param streamApi Pointer to the StreamAPI instance (used for emitting responses)
*/
static void handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi);
/**
* @brief Emit a SerialHalResponse back to the host via StreamAPI framing.
*
* Encodes the response protobuf and sends it with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload).
*
* @param response The response to send
* @param streamApi Pointer to the StreamAPI instance
*/
static void emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi);
private:
/**
* @brief Execute a GPIO pinMode operation.
* @param cmd Decoded SerialHalCommand with PIN_MODE type
* @param response Response object to fill with result
*/
static void handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
/**
* @brief Execute a GPIO digitalWrite operation.
* @param cmd Decoded SerialHalCommand with DIGITAL_WRITE type
* @param response Response object to fill with result
*/
static void handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
/**
* @brief Execute a GPIO digitalRead operation.
* @param cmd Decoded SerialHalCommand with DIGITAL_READ type
* @param response Response object to fill with result (value field contains read result)
*/
static void handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
/**
* @brief Execute an attachInterrupt operation.
* @param cmd Decoded SerialHalCommand with ATTACH_INTERRUPT type
* @param response Response object to fill with result
*/
static void handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
/**
* @brief Execute a detachInterrupt operation.
* @param cmd Decoded SerialHalCommand with DETACH_INTERRUPT type
* @param response Response object to fill with result
*/
static void handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
/**
* @brief Execute an SPI transfer operation.
* @param cmd Decoded SerialHalCommand with SPI_TRANSFER type and data to send
* @param response Response object to fill with result (data field contains received bytes)
*/
static void handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
};
+17 -95
View File
@@ -1,15 +1,12 @@
#include "StreamAPI.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "RedirectablePrint.h"
#include "SerialHalDevice.h"
#include "Throttle.h"
#include "concurrency/LockGuard.h"
#include "configuration.h"
#define START1 0x94
#define START2 0xc3
#define SERIALHAL_MAGIC 0xa5 // second framing byte for SerialHal frames (START1 SH_MAGIC LEN_H LEN_L PAYLOAD)
#define HEADER_LEN 4
int32_t StreamAPI::runOncePart()
@@ -82,29 +79,18 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen)
if (ptr == 0) { // looking for START1
if (c != START1)
rxPtr = 0; // failed to find framing
} else if (ptr == 1) { // discriminate frame type on second byte
if (c == START2) {
rxIsSerialHal = false; // standard ToRadio frame
serialHalRxActive.store(false);
RedirectablePrint::setSerialHalLogSuppressed(false);
} else if (c == SERIALHAL_MAGIC) {
rxIsSerialHal = true; // SerialHal command frame
serialHalRxActive.store(true);
RedirectablePrint::setSerialHalLogSuppressed(true);
} else {
rxPtr = 0; // unrecognised second byte — not our frame
serialHalRxActive.store(false);
RedirectablePrint::setSerialHalLogSuppressed(false);
}
} else if (ptr == 1) { // looking for START2
if (c != START2)
rxPtr = 0; // failed to find framing
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
// console->printf("len %d\n", len);
if (ptr == HEADER_LEN - 1) {
// we _just_ finished our 4 byte header, validate length now
uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE;
if (len > maxLen)
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
// protobuf also)
if (len > MAX_TO_FROM_RADIO_SIZE)
rxPtr = 0; // length is bogus, restart search for framing
}
@@ -112,16 +98,8 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen)
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
rxPtr = 0; // start over again on the next packet
// Dispatch based on which frame type we identified at byte 1
if (rxIsSerialHal)
handleSerialHalCommand(rxBuf + HEADER_LEN, len);
else
handleToRadio(rxBuf + HEADER_LEN, len);
if (rxIsSerialHal)
serialHalRxActive.store(false);
if (rxIsSerialHal)
RedirectablePrint::setSerialHalLogSuppressed(false);
// If we didn't just fail the packet and we now have the right # of bytes, parse it
handleToRadio(rxBuf + HEADER_LEN, len);
}
}
}
@@ -136,12 +114,7 @@ int32_t StreamAPI::readStream()
if (!stream->available()) {
// Nothing available this time, if the computer has talked to us recently, poll often, otherwise let CPU sleep a long time
bool recentRx = Throttle::isWithinTimespanMs(lastRxMsec, 2000);
if (!recentRx)
return 250; // Sleep a long time if we haven't heard from the computer in a while
if (serialHalRxActive.load())
return 0; // If we are in the middle of a SerialHal transaction, don't sleep at all because we want to be as
// responsive as possible to incoming SerialHal bytes
return 5; // Otherwise, poll frequently for new data
return recentRx ? 5 : 250;
} else {
while (stream->available()) { // Currently we never want to block
int cInt = stream->read();
@@ -162,30 +135,18 @@ int32_t StreamAPI::readStream()
if (ptr == 0) { // looking for START1
if (c != START1)
rxPtr = 0; // failed to find framing
} else if (ptr == 1) { // discriminate frame type on second byte
if (c == START2) {
rxIsSerialHal = false; // standard ToRadio frame
serialHalRxActive.store(false);
RedirectablePrint::setSerialHalLogSuppressed(false);
} else if (c == SERIALHAL_MAGIC) {
rxIsSerialHal = true; // SerialHal command frame
serialHalRxActive.store(true);
RedirectablePrint::setSerialHalLogSuppressed(true);
LOG_WARN("StreamAPI: Detected SerialHal command frame");
} else {
rxPtr = 0; // unrecognised second byte — not our frame
serialHalRxActive.store(false);
RedirectablePrint::setSerialHalLogSuppressed(false);
}
} else if (ptr == 1) { // looking for START2
if (c != START2)
rxPtr = 0; // failed to find framing
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
// console->printf("len %d\n", len);
if (ptr == HEADER_LEN - 1) {
// we _just_ finished our 4 byte header, validate length now
uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE;
if (len > maxLen)
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
// protobuf also)
if (len > MAX_TO_FROM_RADIO_SIZE)
rxPtr = 0; // length is bogus, restart search for framing
}
@@ -193,16 +154,8 @@ int32_t StreamAPI::readStream()
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
rxPtr = 0; // start over again on the next packet
// Dispatch based on which frame type we identified at byte 1
if (rxIsSerialHal)
handleSerialHalCommand(rxBuf + HEADER_LEN, len);
else
handleToRadio(rxBuf + HEADER_LEN, len);
if (rxIsSerialHal)
serialHalRxActive.store(false);
if (rxIsSerialHal)
RedirectablePrint::setSerialHalLogSuppressed(false);
// If we didn't just fail the packet and we now have the right # of bytes, parse it
handleToRadio(rxBuf + HEADER_LEN, len);
}
}
}
@@ -246,10 +199,6 @@ void StreamAPI::emitRebooted()
void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg)
{
if (serialHalRxActive.load()) {
return;
}
// IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here — those
// belong to the main packet-emission path and a LOG_ firing during
// `writeStream()` would corrupt an in-flight encode. We keep a
@@ -300,31 +249,4 @@ void StreamAPI::onConnectionChanged(bool connected)
// received a packet in a while
powerFSM.trigger(EVENT_SERIAL_DISCONNECTED);
}
}
void StreamAPI::handleSerialHalCommand(const uint8_t *buf, size_t len)
{
// Default implementation: dispatch to SerialHalDevice for GPIO/SPI handling
SerialHalDevice::handleCommand(buf, len, this);
}
void StreamAPI::emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen)
{
if (hdr == nullptr || hdrLen != 4 || payload == nullptr || payloadLen > meshtastic_SerialHalResponse_size) {
LOG_ERROR("StreamAPI: Invalid SerialHal response parameters");
return;
}
// Build complete frame in a temporary buffer
uint8_t frame[4 + meshtastic_SerialHalResponse_size];
memcpy(frame, hdr, hdrLen);
memcpy(frame + hdrLen, payload, payloadLen);
size_t totalLen = hdrLen + payloadLen;
// Serialize stream writes against other emit operations via streamLock
concurrency::LockGuard guard(&streamLock);
stream->write(frame, totalLen);
stream->flush();
LOG_WARN("StreamAPI: Emitted SerialHal response frame (len=%zu)", totalLen);
}
+2 -26
View File
@@ -4,15 +4,10 @@
#include "Stream.h"
#include "concurrency/Lock.h"
#include "concurrency/OSThread.h"
#include "generated/meshtastic/serial_hal.pb.h"
#include <atomic>
#include <cstdarg>
// Buffer sized for the larger of a full ToRadio/FromRadio payload or a full SerialHalCommand payload, plus header.
#define MAX_STREAM_PAYLOAD_SIZE \
(MAX_TO_FROM_RADIO_SIZE > (int)meshtastic_SerialHalCommand_size ? MAX_TO_FROM_RADIO_SIZE \
: (int)meshtastic_SerialHalCommand_size)
#define MAX_STREAM_BUF_SIZE (MAX_STREAM_PAYLOAD_SIZE + (int)sizeof(uint32_t))
// A To/FromRadio packet + our 32 bit header
#define MAX_STREAM_BUF_SIZE (MAX_TO_FROM_RADIO_SIZE + sizeof(uint32_t))
/**
* A version of our 'phone' API that talks over a Stream. So therefore well suited to use with serial links
@@ -44,8 +39,6 @@ class StreamAPI : public PhoneAPI
uint8_t rxBuf[MAX_STREAM_BUF_SIZE] = {0};
size_t rxPtr = 0;
bool rxIsSerialHal = false; ///< true when the current in-progress frame is a SerialHal frame (START1 SH_MAGIC ...)
std::atomic<bool> serialHalRxActive{false};
/// time of last rx, used, to slow down our polling if we haven't heard from anyone
uint32_t lastRxMsec = 0;
@@ -63,17 +56,6 @@ class StreamAPI : public PhoneAPI
/// Check the current underlying physical link to see if the client is currently connected
virtual bool checkIsConnected() override = 0;
/**
* Emit a SerialHal response frame with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload).
* Called by SerialHalDevice to send responses back to the host.
*
* @param hdr 4-byte header (START1 SERIALHAL_MAGIC LEN_H LEN_L)
* @param hdrLen Length of header (should be 4)
* @param payload Encoded SerialHalResponse protobuf payload
* @param payloadLen Length of payload
*/
void emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen);
private:
/**
* Read any rx chars from the link and call handleToRadio
@@ -93,12 +75,6 @@ class StreamAPI : public PhoneAPI
*/
void emitRebooted();
/**
* Called when a complete SerialHal-framed packet has been received.
* Default implementation dispatches to SerialHalDevice for GPIO/SPI handling.
*/
virtual void handleSerialHalCommand(const uint8_t *buf, size_t len);
virtual void onConnectionChanged(bool connected) override;
/**
-5
View File
@@ -1,7 +1,6 @@
#include "TypeConversions.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include "meshUtils.h"
meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite)
{
@@ -83,10 +82,8 @@ meshtastic_UserLite TypeConversions::ConvertToUserLite(meshtastic_User user)
strncpy(lite.long_name, user.long_name, sizeof(lite.long_name));
lite.long_name[sizeof(lite.long_name) - 1] = '\0';
sanitizeUtf8(lite.long_name, sizeof(lite.long_name));
strncpy(lite.short_name, user.short_name, sizeof(lite.short_name));
lite.short_name[sizeof(lite.short_name) - 1] = '\0';
sanitizeUtf8(lite.short_name, sizeof(lite.short_name));
lite.hw_model = user.hw_model;
lite.role = user.role;
lite.is_licensed = user.is_licensed;
@@ -105,10 +102,8 @@ meshtastic_User TypeConversions::ConvertToUser(uint32_t nodeNum, meshtastic_User
snprintf(user.id, sizeof(user.id), "!%08x", nodeNum);
strncpy(user.long_name, lite.long_name, sizeof(user.long_name));
user.long_name[sizeof(user.long_name) - 1] = '\0';
sanitizeUtf8(user.long_name, sizeof(user.long_name));
strncpy(user.short_name, lite.short_name, sizeof(user.short_name));
user.short_name[sizeof(user.short_name) - 1] = '\0';
sanitizeUtf8(user.short_name, sizeof(user.short_name));
user.hw_model = lite.hw_model;
user.role = lite.role;
user.is_licensed = lite.is_licensed;
+1 -1
View File
@@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg;
/* Maximum encoded size of messages (where known) */
#define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size
#define meshtastic_ChannelSet_size 685
#define meshtastic_ChannelSet_size 682
#ifdef __cplusplus
} /* extern "C" */
+10 -38
View File
@@ -289,14 +289,7 @@ typedef enum _meshtastic_Config_LoRaConfig_RegionCode {
/* ITU Region 1 Amateur Radio 2m band (144-146 MHz) */
meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M = 27,
/* ITU Region 2 / 3 Amateur Radio 2m band (144-148 MHz) */
meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M = 28,
/* EU 866MHz band (Band no. 47b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) */
meshtastic_Config_LoRaConfig_RegionCode_EU_866 = 29,
/* EU 874MHz and 917MHz bands (Band no. 1 and 4 of 2022/172/EC and subsequent amendments) for Non-specific short-range devices (SRD) */
meshtastic_Config_LoRaConfig_RegionCode_EU_874 = 30,
meshtastic_Config_LoRaConfig_RegionCode_EU_917 = 31,
/* EU 868MHz band, with narrow presets */
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868 = 32
meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M = 28
} meshtastic_Config_LoRaConfig_RegionCode;
/* Standard predefined channel settings
@@ -326,24 +319,7 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset {
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO = 8,
/* Long Range - Turbo
This preset performs similarly to LongFast, but with 500Khz bandwidth. */
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9,
/* Lite Fast
Medium range preset optimized for EU 866MHz SRD band with 125kHz bandwidth.
Comparable link budget to MEDIUM_FAST but compliant with Band no. 47b of 2006/771/EC. */
meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST = 10,
/* Lite Slow
Medium-to-moderate range preset optimized for EU 866MHz SRD band with 125kHz bandwidth.
Comparable link budget to LONG_FAST but compliant with Band no. 47b of 2006/771/EC. */
meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW = 11,
/* Narrow Fast
Medium-to-moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth.
Comparable link budget to SHORT_SLOW, but with half the data rate.
Intended to avoid interference with other devices. */
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST = 12,
/* Narrow Slow
Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth.
Comparable link budget and data rate to LONG_FAST. */
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9
} meshtastic_Config_LoRaConfig_ModemPreset;
typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode {
@@ -618,8 +594,6 @@ typedef struct _meshtastic_Config_LoRaConfig {
bool config_ok_to_mqtt;
/* Set where LORA FEM is enabled, disabled, or not present */
meshtastic_Config_LoRaConfig_FEM_LNA_Mode fem_lna_mode;
/* Don't use radiolib to initialize the radio, instead listen for a serialHal connection */
bool serial_hal_only;
} meshtastic_Config_LoRaConfig;
typedef struct _meshtastic_Config_BluetoothConfig {
@@ -732,12 +706,12 @@ extern "C" {
#define _meshtastic_Config_DisplayConfig_CompassOrientation_ARRAYSIZE ((meshtastic_Config_DisplayConfig_CompassOrientation)(meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED+1))
#define _meshtastic_Config_LoRaConfig_RegionCode_MIN meshtastic_Config_LoRaConfig_RegionCode_UNSET
#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_EU_N_868
#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868+1))
#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M
#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M+1))
#define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST
#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW
#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1))
#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO
#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO+1))
#define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED
#define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT
@@ -781,7 +755,7 @@ extern "C" {
#define meshtastic_Config_NetworkConfig_init_default {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_default, "", 0, 0}
#define meshtastic_Config_NetworkConfig_IpV4Config_init_default {0, 0, 0, 0}
#define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0}
#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0}
#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN}
#define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0}
#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0}
#define meshtastic_Config_SessionkeyConfig_init_default {0}
@@ -792,7 +766,7 @@ extern "C" {
#define meshtastic_Config_NetworkConfig_init_zero {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_zero, "", 0, 0}
#define meshtastic_Config_NetworkConfig_IpV4Config_init_zero {0, 0, 0, 0}
#define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0}
#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0}
#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN}
#define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0}
#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0}
#define meshtastic_Config_SessionkeyConfig_init_zero {0}
@@ -879,7 +853,6 @@ extern "C" {
#define meshtastic_Config_LoRaConfig_ignore_mqtt_tag 104
#define meshtastic_Config_LoRaConfig_config_ok_to_mqtt_tag 105
#define meshtastic_Config_LoRaConfig_fem_lna_mode_tag 106
#define meshtastic_Config_LoRaConfig_serial_hal_only_tag 107
#define meshtastic_Config_BluetoothConfig_enabled_tag 1
#define meshtastic_Config_BluetoothConfig_mode_tag 2
#define meshtastic_Config_BluetoothConfig_fixed_pin_tag 3
@@ -1032,8 +1005,7 @@ X(a, STATIC, SINGULAR, BOOL, pa_fan_disabled, 15) \
X(a, STATIC, REPEATED, UINT32, ignore_incoming, 103) \
X(a, STATIC, SINGULAR, BOOL, ignore_mqtt, 104) \
X(a, STATIC, SINGULAR, BOOL, config_ok_to_mqtt, 105) \
X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) \
X(a, STATIC, SINGULAR, BOOL, serial_hal_only, 107)
X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106)
#define meshtastic_Config_LoRaConfig_CALLBACK NULL
#define meshtastic_Config_LoRaConfig_DEFAULT NULL
@@ -1090,7 +1062,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg;
#define meshtastic_Config_BluetoothConfig_size 10
#define meshtastic_Config_DeviceConfig_size 100
#define meshtastic_Config_DisplayConfig_size 36
#define meshtastic_Config_LoRaConfig_size 91
#define meshtastic_Config_LoRaConfig_size 88
#define meshtastic_Config_NetworkConfig_IpV4Config_size 20
#define meshtastic_Config_NetworkConfig_size 204
#define meshtastic_Config_PositionConfig_size 62
@@ -361,7 +361,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
/* Maximum encoded size of messages (where known) */
/* meshtastic_NodeDatabase_size depends on runtime parameters */
#define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size
#define meshtastic_BackupPreferences_size 2432
#define meshtastic_BackupPreferences_size 2429
#define meshtastic_ChannelFile_size 718
#define meshtastic_DeviceState_size 1737
#define meshtastic_NodeInfoLite_size 196

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