Compare commits

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

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

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

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

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

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

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

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

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

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

* Theme support - New and some Classic Themes!

* Colored Compass

---------

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

Aligns t5s3_epaper with other variants like t_deck_pro.

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

* Applet Switcher

* Update ED047TC1.cpp

* trunk fix

* Custom tip screen for T5s3

* Update TouchScreenImpl1.cpp

* Update ED047TC1.cpp

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

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

* Gate PMU IRQ behind a found PMU
2026-04-24 06:48:21 -05:00
Jonathan BennettandBen Meadors d47301defc Add PortduinoSetOptions to overwrite the realhardware bool (#10157)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-24 06:48:09 -05:00
Jonathan Bennett 04b819a7b5 Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280)
Fixes #9912  and #10170
2026-04-24 06:47:38 -05:00
github-actions[bot]andvidplace7 8e653122c7 Upgrade trunk (#10284)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-24 05:40:44 -05:00
renovate[bot] 5ea3d143da Update meshtastic-esp8266-oled-ssd1306 digest to 6bfd1f1 (#10277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 19:30:47 -05:00
Ben MeadorsandCopilot 56c897e826 Can't LOG when we don't have logging set up yet in the native test suite
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 14:42:29 -05:00
Ben Meadors 2cc13a1132 Sane sanitization 2026-04-23 14:19:33 -05:00
Jonathan Bennett 031f332ec1 We have HardwareRNG, let's use it! (#10274) 2026-04-23 13:16:49 -05:00
118 changed files with 6299 additions and 1426 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ plugins:
lint:
enabled:
- checkov@3.2.524
- renovate@43.139.6
- renovate@43.141.0
- prettier@3.8.3
- trufflehog@3.95.2
- yamllint@1.38.0
+2 -1
View File
@@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION
pio pkg install --environment "$PIO_ENV" || platformioFailed
pio run --environment "$PIO_ENV" || platformioFailed
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)"
os_name=$(uname -s | tr '[:upper:]' '[:lower:]')
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)"
cp bin/native-install.* $OUTDIR/
+118
View File
@@ -0,0 +1,118 @@
#!/bin/bash
# Script to show commits in develop that are not in master
# with their associated PR info and commit hashes
#
# Usage:
# ./show-unmerged-prs.sh # Show all unmerged commits
# ./show-unmerged-prs.sh --bugfix # Show only bugfix-labeled PRs
set -e
REPO="firmware"
OWNER="meshtastic"
BASE_BRANCH="master"
HEAD_BRANCH="develop"
LIMIT=100
FILTER_LABEL=""
# Parse arguments
for arg in "$@"; do
case $arg in
--bugfix)
FILTER_LABEL="bugfix"
shift
;;
--feature)
FILTER_LABEL="feature"
shift
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --bugfix Show only PRs labeled with 'bugfix'"
echo " --feature Show only PRs labeled with 'feature'"
echo " --help Show this help message"
exit 0
;;
esac
done
if [ -n "$FILTER_LABEL" ]; then
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH (filtered by label: $FILTER_LABEL)..."
else
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH..."
fi
echo ""
# Check if gh CLI is available
if ! command -v gh &> /dev/null; then
echo "ERROR: GitHub CLI (gh) not found. Please install it first."
echo "Visit: https://cli.github.com/"
exit 1
fi
# Get commits in develop that are not in master
# For each commit, try to find associated PR
git fetch origin develop master 2>/dev/null || true
# Use git to get the list of commits
commits=$(git log --pretty=format:"%H|%s" origin/master..origin/develop | head -n $LIMIT)
count=0
displayed=0
echo "Commits in $HEAD_BRANCH not in $BASE_BRANCH:"
echo "=============================================="
echo ""
while IFS='|' read -r hash subject; do
((count++))
# Try to find the PR for this commit
# Extract PR number, title, description, and labels
pr_response=$(gh api -X GET "/repos/$OWNER/$REPO/commits/$hash/pulls" \
-H "Accept: application/vnd.github.v3+json" 2>/dev/null | \
jq -r '.[0] | "\(.number)|\(.title)|\(.body // "No description")|\(.labels | map(.name) | join(","))"' 2>/dev/null || echo "||||")
if [ -z "$pr_response" ] || [ "$pr_response" = "||||" ]; then
# If no PR found, skip if filter is active, otherwise show the commit
if [ -z "$FILTER_LABEL" ]; then
((displayed++))
echo "[$displayed] Commit: $hash"
echo " Subject: $subject"
echo " PR: Not found in GitHub"
echo ""
fi
else
IFS='|' read -r pr_num pr_title pr_desc pr_labels <<< "$pr_response"
# Check if filter matches
if [ -n "$FILTER_LABEL" ]; then
# Only show if the label is in the labels list
if ! echo "$pr_labels" | grep -q "$FILTER_LABEL"; then
continue
fi
fi
((displayed++))
echo "[$displayed] PR #$pr_num - $pr_title"
echo " Commit: $hash"
if [ -n "$pr_desc" ] && [ "$pr_desc" != "No description" ]; then
# Truncate description to 200 chars
desc_short="${pr_desc:0:200}"
[ ${#pr_desc} -gt 200 ] && desc_short+="..."
echo " Description: $desc_short"
fi
if [ -n "$pr_labels" ] && [ "$pr_labels" != "" ]; then
echo " Labels: $pr_labels"
fi
echo ""
fi
done <<< "$commits"
echo ""
if [ -n "$FILTER_LABEL" ]; then
echo "Done. Showing $displayed PRs with label '$FILTER_LABEL' from $displayed commits checked."
else
echo "Done. Showing $displayed commits from $HEAD_BRANCH not in $BASE_BRANCH."
fi
+2 -1
View File
@@ -29,6 +29,7 @@ build_flags = -Wno-missing-field-initializers
-DUSE_THREAD_NAMES
-DTINYGPS_OPTION_NO_CUSTOM_FIELDS
-DPB_ENABLE_MALLOC=1
-DPB_VALIDATE_UTF8=1
-DRADIOLIB_EXCLUDE_CC1101=1
-DRADIOLIB_EXCLUDE_NRF24=1
-DRADIOLIB_EXCLUDE_RF69=1
@@ -66,7 +67,7 @@ monitor_speed = 115200
monitor_filters = direct
lib_deps =
# renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/21e484f409cde18d44012caef84c244eb5ca28f3.zip
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip
# renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master
https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip
# renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master
+100 -12
View File
@@ -68,7 +68,11 @@
#define ETH ETH2
#endif // HAS_ETHERNET
#endif // MQTT
#endif
#ifndef DELAY_FOREVER
#define DELAY_FOREVER portMAX_DELAY
#endif
#if defined(BATTERY_PIN) && defined(ARCH_ESP32)
@@ -90,6 +94,34 @@ static const adc_atten_t atten = ADC_ATTENUATION;
#endif
#endif // BATTERY_PIN && ARCH_ESP32
#ifdef EXT_PWR_DETECT
#ifndef EXT_PWR_DETECT_MODE
#define EXT_PWR_DETECT_MODE INPUT
// If using internal pull resistors, we can infer EXT_PWR_DETECT_VALUE
#elif EXT_PWR_DETECT_MODE == INPUT_PULLUP
#define EXT_PWR_DETECT_VALUE LOW
#elif EXT_PWR_DETECT_MODE == INPUT_PULLDOWN
#define EXT_PWR_DETECT_VALUE HIGH
#endif
#ifndef EXT_PWR_DETECT_VALUE
#define EXT_PWR_DETECT_VALUE HIGH
#endif
#endif
#ifdef EXT_CHRG_DETECT
#ifndef EXT_CHRG_DETECT_MODE
#define EXT_CHRG_DETECT_MODE INPUT
// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP
#define EXT_CHRG_DETECT_VALUE LOW
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN
#define EXT_CHRG_DETECT_VALUE HIGH
#endif
#ifndef EXT_CHRG_DETECT_VALUE
#define EXT_CHRG_DETECT_VALUE HIGH
#endif
#endif
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
#if __has_include(<Adafruit_INA219.h>)
INA219Sensor ina219Sensor;
@@ -128,7 +160,7 @@ MAX17048Sensor max17048Sensor;
NullSensor max17048Sensor;
#endif
#endif
#endif // !MESHTASTIC_EXCLUDE_I2C
#endif
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && HAS_RAKPROT
RAK9154Sensor rak9154Sensor;
@@ -146,12 +178,46 @@ XPowersPPM *PPM = NULL;
#ifdef HAS_PMU
XPowersLibInterface *PMU = NULL;
#else
// Copy of the base class defined in axp20x.h.
// I'd rather not include axp20x.h as it brings Wire dependency.
class HasBatteryLevel
{
public:
/**
* Battery state of charge, from 0 to 100 or -1 for unknown
*/
virtual int getBatteryPercent() { return -1; }
/**
* The raw voltage of the battery or NAN if unknown
*/
virtual uint16_t getBattVoltage() { return 0; }
/**
* return true if there is a battery installed in this unit
*/
virtual bool isBatteryConnect() { return false; }
virtual bool isVbusIn() { return false; }
virtual bool isCharging() { return false; }
};
#endif
bool pmu_irq = false;
Power *power;
using namespace meshtastic;
// NRF52 has AREF_VOLTAGE defined in architecture.h but
// make sure it's included. If something is wrong with NRF52
// definition - compilation will fail on missing definition
#if !defined(AREF_VOLTAGE) && !defined(ARCH_NRF52)
#define AREF_VOLTAGE 3.3
#endif
/**
* If this board has a battery level sensor, set this to a valid implementation
*/
@@ -194,7 +260,7 @@ static void battery_adcDisable()
#endif
}
#endif // BATTERY_PIN
#endif
/**
* A simple battery level sensor that assumes the battery voltage is attached
@@ -253,7 +319,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
}
/**
* The raw voltage of the battery in millivolts or NAN if unknown
* The raw voltage of the batteryin millivolts or NAN if unknown
*/
virtual uint16_t getBattVoltage() override
{
@@ -270,6 +336,16 @@ class AnalogBatteryLevel : public HasBatteryLevel
}
#endif
#ifndef ADC_MULTIPLIER
#define ADC_MULTIPLIER 2.0
#endif
#ifndef BATTERY_SENSE_SAMPLES
#define BATTERY_SENSE_SAMPLES \
15 // Set the number of samples, it has an effect of increasing sensitivity in
// complex electromagnetic environment.
#endif
#ifdef BATTERY_PIN
// Override variant or default ADC_MULTIPLIER if we have the override pref
float operativeAdcMultiplier =
@@ -705,8 +781,10 @@ void Power::reboot()
rp2040.reboot();
#elif defined(ARCH_PORTDUINO)
deInitApiServer();
#ifdef __linux__
if (aLinuxInputImpl)
aLinuxInputImpl->deInit();
#endif
SPI.end();
Wire.end();
Serial1.end();
@@ -1017,7 +1095,7 @@ void Power::attachPowerInterrupts()
attachInterrupt(
PMU_IRQ,
[]() {
power->pmu_irq = true;
pmu_irq = true;
power->setIntervalFromNow(0);
runASAP = true;
},
@@ -1636,7 +1714,7 @@ bool Power::lipoChargerInit()
return true;
}
#else // HAS_PPM
#else
/**
* The Lipo battery level sensor is unavailable - default to AnalogBatteryLevel
*/
@@ -1644,7 +1722,7 @@ bool Power::lipoChargerInit()
{
return false;
}
#endif // HAS_PPM
#endif
#ifdef HELTEC_MESH_SOLAR
#include "meshSolarApp.h"
@@ -1706,7 +1784,7 @@ bool Power::meshSolarInit()
return true;
}
#else // HELTEC_MESH_SOLAR
#else
/**
* The meshSolar battery level sensor is unavailable - default to
* AnalogBatteryLevel
@@ -1715,7 +1793,7 @@ bool Power::meshSolarInit()
{
return false;
}
#endif // HELTEC_MESH_SOLAR
#endif
#ifdef HAS_SERIAL_BATTERY_LEVEL
#include <SoftwareSerial.h>
@@ -1723,7 +1801,7 @@ bool Power::meshSolarInit()
/**
* SerialBatteryLevel class for pulling battery information from a secondary MCU over serial.
*/
class SerialBatteryLevel : public AnalogBatteryLevel
class SerialBatteryLevel : public HasBatteryLevel
{
public:
@@ -1800,6 +1878,16 @@ class SerialBatteryLevel : public AnalogBatteryLevel
return false;
}
virtual bool isCharging() override
{
#ifdef EXT_CHRG_DETECT
return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
#endif
// by default, we check the battery voltage only
return isVbusIn();
}
private:
SoftwareSerial BatterySerial = SoftwareSerial(SERIAL_BATTERY_RX, SERIAL_BATTERY_TX);
uint8_t Data[6] = {0};
@@ -1829,7 +1917,7 @@ bool Power::serialBatteryInit()
return true;
}
#else // HAS_SERIAL_BATTERY_LEVEL
#else
/**
* If this device has no serial battery level sensor, don't try to use it.
*/
@@ -1837,4 +1925,4 @@ bool Power::serialBatteryInit()
{
return false;
}
#endif // HAS_SERIAL_BATTERY_LEVEL
#endif
+48 -11
View File
@@ -58,6 +58,35 @@ static bool isPowered()
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
}
#if defined(T5_S3_EPAPER_PRO)
static void t5BacklightOffForSleep()
{
t5BacklightSetForcedBySleep(true);
}
static void t5BacklightWakeFromSleep()
{
t5BacklightSetForcedBySleep(false);
}
static void t5BacklightOffForTimeout()
{
t5BacklightSetForcedByTimeout(true);
t5TouchSetForcedByTimeout(true);
}
static void t5BacklightOnFromUserInput()
{
t5BacklightHandleUserInput();
t5TouchHandleUserInput();
}
#else
static void t5BacklightOffForSleep() {}
static void t5BacklightWakeFromSleep() {}
static void t5BacklightOffForTimeout() {}
static void t5BacklightOnFromUserInput() {}
#endif
static void sdsEnter()
{
LOG_POWERFSM("State: SDS");
@@ -87,6 +116,7 @@ static void lsEnter()
LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs);
if (screen)
screen->setOn(false);
t5BacklightOffForSleep();
secsSlept = 0; // How long have we been sleeping this time
// LOG_INFO("lsEnter end");
@@ -159,6 +189,8 @@ static void lsIdle()
static void lsExit()
{
LOG_POWERFSM("State: lsExit");
// Lift the light-sleep force-off gate when leaving LS.
t5BacklightWakeFromSleep();
}
static void nbEnter()
@@ -180,6 +212,8 @@ static void darkEnter()
setBluetoothEnable(true);
if (screen)
screen->setOn(false);
// Screen timeout enters DARK; ensure backlight also turns off.
t5BacklightOffForTimeout();
}
static void serialEnter()
@@ -289,12 +323,13 @@ void PowerFSM_setup()
powerFSM.add_transition(&stateNB, &stateNB, EVENT_PACKET_FOR_PHONE, NULL, "Received packet, resetting win wake");
// Handle press events - note: we ignore button presses when in API mode
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press");
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press");
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press");
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press");
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL,
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput,
"Press"); // reenter On to restart our timers
powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, t5BacklightOnFromUserInput,
"Press"); // Allow button to work while in serial API
// Handle critically low power battery by forcing deep sleep
@@ -314,11 +349,13 @@ void PowerFSM_setup()
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
// Inputbroker
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, NULL, "Input Device");
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, NULL, "Input Device");
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, NULL, "Input Device");
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput,
"Input Device"); // restarts the sleep timer
powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, t5BacklightOnFromUserInput,
"Input Device"); // restarts the sleep timer
powerFSM.add_transition(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
powerFSM.add_transition(&stateON, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
+1 -1
View File
@@ -1,8 +1,8 @@
#pragma once
#include "../freertosinc.h"
#include "Print.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <Print.h>
#include <stdarg.h>
#include <string>
+59 -53
View File
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "draw/NodeListRenderer.h"
#include "draw/NotificationRenderer.h"
#include "draw/UIRenderer.h"
#include "graphics/TFTColorRegions.h"
#include "modules/CannedMessageModule.h"
#if !MESHTASTIC_EXCLUDE_GPS
@@ -54,6 +55,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTPalette.h"
#include "graphics/emotes.h"
#include "graphics/images.h"
#include "input/TouchScreenImpl1.h"
@@ -69,12 +71,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "target_specific.h"
extern MessageStore messageStore;
#if USE_TFTDISPLAY
extern uint16_t TFT_MESH;
#else
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
#endif
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
#include "mesh/wifi/WiFiAPClient.h"
#endif
@@ -109,6 +105,27 @@ namespace graphics
// A text message frame + debug frame + all the node infos
FrameCallback *normalFrames;
static uint32_t targetFramerate = IDLE_FRAMERATE;
#if GRAPHICS_TFT_COLORING_ENABLED
static inline void prepareFrameColorRegions()
{
#if GRAPHICS_TFT_COLORING_ENABLED
clearTFTColorRegions();
// Full-frame FrameMono inversion for themes that need it (e.g. light themes).
if (isThemeFullFrameInvert()) {
setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(),
screen->getHeight());
}
#endif
}
#endif
static inline void updateUiFrame(OLEDDisplayUi *ui)
{
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
ui->update();
}
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
@@ -227,7 +244,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
@@ -249,7 +266,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
@@ -273,7 +290,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
@@ -296,7 +313,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
ui->update();
updateUiFrame(ui);
}
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
@@ -388,30 +405,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
{
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
int32_t rawRGB = uiconfig.screen_rgb_color;
// Only validate the combined value once
if (rawRGB > 0 && rawRGB <= 255255255) {
LOG_INFO("Setting screen RGB color to user chosen: 0x%06X", rawRGB);
// Extract each component as a normal int first
int r = (rawRGB >> 16) & 0xFF;
int g = (rawRGB >> 8) & 0xFF;
int b = rawRGB & 0xFF;
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
TFT_MESH = COLOR565(static_cast<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(b));
}
#ifdef TFT_MESH_OVERRIDE
} else if (rawRGB == 0) {
LOG_INFO("Setting screen RGB color to TFT_MESH_OVERRIDE: 0x%04X", TFT_MESH_OVERRIDE);
// Default to TFT_MESH_OVERRIDE if available
TFT_MESH = TFT_MESH_OVERRIDE;
#endif
} else {
// Default best readable yellow color
LOG_INFO("Setting screen RGB color to default: (255,255,128)");
TFT_MESH = COLOR565(255, 255, 128);
}
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -474,9 +467,13 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
#endif
#if defined(USE_ST7789)
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
// Keep firmware and ST7789 driver region structs layout-compatible:
// we pass `graphics::colorRegions` through a type cast below.
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
#elif defined(USE_ST7796)
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
#endif
ui = new OLEDDisplayUi(dispdev);
@@ -663,16 +660,16 @@ void Screen::setup()
static_cast<SH1106Wire *>(dispdev)->setSubtype(7);
#endif
#if defined(USE_ST7789) && defined(TFT_MESH)
// Apply custom RGB color (e.g. Heltec T114/T190)
static_cast<ST7789Spi *>(dispdev)->setRGB(TFT_MESH);
#if defined(USE_ST7789)
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
#endif
#if defined(MUZI_BASE)
dispdev->delayPoweron = true;
#endif
#if defined(USE_ST7796) && defined(TFT_MESH)
// Custom text color, if defined in variant.h
static_cast<ST7796Spi *>(dispdev)->setRGB(TFT_MESH);
#if defined(USE_ST7796)
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
#endif
// Initialize display and UI system
@@ -718,7 +715,7 @@ void Screen::setup()
#endif
{
const char *region = myRegion ? myRegion->name : nullptr;
graphics::UIRenderer::drawIconScreen(region, display, state, x, y);
graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y);
}
};
ui->setFrames(alertFrames, 1);
@@ -757,9 +754,9 @@ void Screen::setup()
// Turn on display and trigger first draw
handleSetOn(true);
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
ui->update();
updateUiFrame(ui);
#ifndef USE_EINK
ui->update(); // Some SSD1306 clones drop the first draw, so run twice
updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice
#endif
serialSinceMsec = millis();
@@ -832,7 +829,7 @@ void Screen::forceDisplay(bool forceUiUpdate)
do {
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
delay(10);
ui->update();
updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
// Return to normal frame rate
@@ -903,9 +900,9 @@ int32_t Screen::runOnce()
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
ui->update();
updateUiFrame(ui);
#ifndef USE_EINK
ui->update();
updateUiFrame(ui);
#endif
showingOEMBootScreen = false;
}
@@ -996,7 +993,7 @@ int32_t Screen::runOnce()
// this must be before the frameState == FIXED check, because we always
// want to draw at least one FIXED frame before doing forceDisplay
ui->update();
updateUiFrame(ui);
// Switch to a low framerate (to save CPU) when we are not in transition
// but we should only call setTargetFPS when framestate changes, because
@@ -1058,7 +1055,7 @@ void Screen::setSSLFrames()
// LOG_DEBUG("Show SSL frames");
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
ui->setFrames(sslFrames, 1);
ui->update();
updateUiFrame(ui);
}
}
@@ -1094,7 +1091,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
do {
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
delay(1);
ui->update();
updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
#if defined(USE_EINK_PARALLELDISPLAY)
@@ -1469,9 +1466,15 @@ void Screen::blink()
dispdev->setBrightness(254);
while (count > 0) {
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
delay(50);
dispdev->clear();
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
delay(50);
count = count - 1;
@@ -1605,6 +1608,9 @@ void Screen::setFastFramerate()
{
#if defined(M5STACK_UNITC6L)
dispdev->clear();
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
#endif
// We are about to start a transition so speed up fps
@@ -1816,7 +1822,7 @@ int Screen::handleInputEvent(const InputEvent *event)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
setFastFramerate(); // Draw ASAP
ui->update();
updateUiFrame(ui);
return 0;
}
@@ -1831,7 +1837,7 @@ int Screen::handleInputEvent(const InputEvent *event)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
setFastFramerate(); // Draw ASAP
ui->update();
updateUiFrame(ui);
menuHandler::handleMenuSwitch(dispdev);
return 0;
+260 -60
View File
@@ -1,16 +1,20 @@
#include "configuration.h"
#if HAS_SCREEN
#include "MeshService.h"
#include "NodeDB.h"
#include "RTC.h"
#include "draw/NodeListRenderer.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/draw/UIRenderer.h"
#include "main.h"
#include "meshtastic/config.pb.h"
#include "modules/ExternalNotificationModule.h"
#include "power.h"
#include <OLEDDisplay.h>
#include <cctype>
#include <graphics/images.h>
namespace graphics
@@ -65,6 +69,12 @@ uint32_t lastBlinkShared = 0;
bool isMailIconVisible = true;
uint32_t lastMailBlink = 0;
static inline bool useClockHeaderAccentTheme(uint32_t themeId)
{
return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen ||
themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
}
// *********************************
// * Rounded Header when inverted *
// *********************************
@@ -85,7 +95,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
// *************************
// * Common Header Drawing *
// *************************
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date)
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date,
bool transparent_background, bool use_title_color_override, uint16_t title_color_override)
{
constexpr int HEADER_OFFSET_Y = 1;
y += HEADER_OFFSET_Y;
@@ -100,30 +111,93 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
const int screenW = display->getWidth();
const int screenH = display->getHeight();
const int headerHeight = highlightHeight + 2;
const uint16_t headerColorForRoles = getThemeHeaderBg();
// Color TFT headers use a fixed dark background + white glyphs.
// Keep legacy inverted bitmap behavior only for monochrome displays.
const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background);
#if GRAPHICS_TFT_COLORING_ENABLED
int statusLeftEndX = 0;
int statusRightStartX = screenW;
const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0');
const auto activeThemeId = getActiveTheme().id;
const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId);
#endif
{
const uint16_t headerColor = getThemeHeaderBg();
const uint16_t headerTextColor = getThemeHeaderText();
const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor;
uint16_t headerStatusColor = getThemeHeaderStatus();
#if GRAPHICS_TFT_COLORING_ENABLED
// Clock frame uses transparent header + date + empty title.
// For accent clock themes (Pink/Creamsicle + classic monochrome), tint
// status items (battery outline, %, date, mail icon) to the header accent.
if (useClockHeaderAccent) {
headerStatusColor = getThemeHeaderBg();
}
if (transparent_background) {
// Transparent clock headers should inherit whatever body off-color is
// already active under the header (important for light/inverted themes).
const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg());
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW,
headerHeight);
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor);
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor);
} else if (useInvertedHeaderStyle) {
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW,
headerHeight);
setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole);
setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor);
} else {
setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW,
headerHeight);
setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor);
setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor);
}
#endif
if (!force_no_invert) {
// === Inverted Header Background ===
if (isInverted) {
if (useInvertedHeaderStyle) {
display->setColor(BLACK);
display->fillRect(0, 0, screenW, highlightHeight + 2);
display->fillRect(0, 0, screenW, headerHeight);
display->setColor(WHITE);
drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
display->setColor(BLACK);
} else {
display->setColor(BLACK);
display->fillRect(0, 0, screenW, highlightHeight + 2);
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
display->drawLine(0, 20, screenW, 20);
} else {
display->drawLine(0, 14, screenW, 14);
display->fillRect(0, 0, screenW, headerHeight);
// Keep the legacy white separator for monochrome displays only when header background is visible.
#if !GRAPHICS_TFT_COLORING_ENABLED
if (!transparent_background) {
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
display->drawLine(0, 20, screenW, 20);
} else {
display->drawLine(0, 14, screenW, 14);
}
}
#endif
}
if (transparent_background) {
display->setColor(WHITE);
}
#if GRAPHICS_TFT_COLORING_ENABLED
// TFT role coloring expects foreground glyph bits to be "set".
display->setColor(WHITE);
#endif
// === Screen Title ===
const char *headerTitle = titleStr ? titleStr : "";
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
#if GRAPHICS_TFT_COLORING_ENABLED
const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2);
registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL);
#endif
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
}
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -152,6 +226,17 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH);
const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
bool hasBatteryFillRegion = false;
int16_t batteryFillRegionX = 0;
int16_t batteryFillRegionY = 0;
int16_t batteryFillRegionW = 0;
int16_t batteryFillRegionH = 0;
#if GRAPHICS_TFT_COLORING_ENABLED
uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent);
if (useClockHeaderAccent) {
batteryFillColor = getThemeHeaderBg();
}
#endif
int batteryX = 1;
int batteryY = HEADER_OFFSET_Y + 1;
@@ -180,6 +265,15 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12);
int fillWidth = 14 * chargePercent / 100;
display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11);
#if GRAPHICS_TFT_COLORING_ENABLED
if (fillWidth > 0) {
hasBatteryFillRegion = true;
batteryFillRegionX = batteryX + 1;
batteryFillRegionY = batteryY + 1;
batteryFillRegionW = fillWidth;
batteryFillRegionH = 11;
}
#endif
}
batteryX += 18; // Icon + 2 pixels
} else {
@@ -194,21 +288,41 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int fillHeight = 8 * chargePercent / 100;
int fillY = batteryY - fillHeight;
display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight);
#if GRAPHICS_TFT_COLORING_ENABLED
if (fillHeight > 0) {
hasBatteryFillRegion = true;
batteryFillRegionX = batteryX + 1;
batteryFillRegionY = fillY + 10;
batteryFillRegionW = 5;
batteryFillRegionH = fillHeight;
}
#endif
}
batteryX += 9; // Icon + 2 pixels
}
}
#if GRAPHICS_TFT_COLORING_ENABLED
statusLeftEndX = batteryX + 2;
#endif
if (chargePercent != 101) {
// === Battery % Display ===
char chargeStr[4];
snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent);
int chargeNumWidth = display->getStringWidth(chargeStr);
const int percentWidth = display->getStringWidth("%");
const int percentX = batteryX + chargeNumWidth - 1;
display->drawString(batteryX, textY, chargeStr);
display->drawString(batteryX + chargeNumWidth - 1, textY, "%");
display->drawString(percentX, textY, "%");
#if GRAPHICS_TFT_COLORING_ENABLED
statusLeftEndX = percentX + percentWidth + 2;
#endif
if (isBold) {
display->drawString(batteryX + 1, textY, chargeStr);
display->drawString(batteryX + chargeNumWidth, textY, "%");
display->drawString(percentX + 1, textY, "%");
#if GRAPHICS_TFT_COLORING_ENABLED
statusLeftEndX = percentX + percentWidth + 3;
#endif
}
}
@@ -253,6 +367,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
timeStrWidth = display->getStringWidth(timeStr);
}
timeX = screenW - xOffset - timeStrWidth + 3;
#if GRAPHICS_TFT_COLORING_ENABLED
statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16);
#endif
// === Show Mail or Mute Icon to the Left of Time ===
int iconRightEdge = timeX - 2;
@@ -278,7 +395,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int iconW = 16, iconH = 12;
int iconX = iconRightEdge - iconW;
int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
if (isInverted && !force_no_invert) {
if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
display->setColor(BLACK);
@@ -293,7 +410,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
} else {
int iconX = iconRightEdge - (mail_width - 2);
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
if (isInverted && !force_no_invert) {
if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
display->setColor(BLACK);
@@ -309,7 +426,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int iconX = iconRightEdge - mute_symbol_big_width;
int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
if (isInverted && !force_no_invert) {
if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
display->setColor(BLACK);
@@ -323,7 +440,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int iconX = iconRightEdge - mute_symbol_width;
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
if (isInverted && !force_no_invert) {
if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
display->setColor(BLACK);
@@ -351,7 +468,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
} else {
// === No Time Available: Mail/Mute Icon Moves to Far Right ===
int iconRightEdge = screenW - xOffset;
#if GRAPHICS_TFT_COLORING_ENABLED
statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12);
#endif
bool showMail = false;
#ifndef USE_EINK
@@ -393,6 +512,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
}
}
}
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight);
if (statusRightStartX < screenW) {
registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight);
}
if (hasBatteryFillRegion) {
registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH,
batteryFillColor, headerColorForRoles);
}
#endif
display->setColor(WHITE); // Reset for other UI
}
@@ -430,14 +559,23 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
return;
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale);
const int footerH = (connection_icon_height * scale) + (2 * scale);
const int iconX = 0;
const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale);
const int iconW = connection_icon_width * scale;
const int iconH = connection_icon_height * scale;
#if GRAPHICS_TFT_COLORING_ENABLED
// Only tint the link glyph itself on TFT; keep the footer background black.
setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH);
#endif
display->setColor(BLACK);
display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
(connection_icon_height * scale) + (2 * scale));
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
const int bytesPerRow = (connection_icon_width + 7) / 8;
int iconX = 0;
int iconY = SCREEN_HEIGHT - (connection_icon_height * 2);
for (int yy = 0; yy < connection_icon_height; ++yy) {
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
@@ -451,65 +589,127 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
}
} else {
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
connection_icon);
display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon);
}
}
bool isAllowedPunctuation(char c)
{
const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ ";
return allowed.find(c) != std::string::npos;
switch (c) {
case '.':
case ',':
case '!':
case '?':
case ';':
case ':':
case '-':
case '_':
case '(':
case ')':
case '[':
case ']':
case '{':
case '}':
case '\'':
case '"':
case '@':
case '#':
case '$':
case '/':
case '\\':
case '&':
case '+':
case '=':
case '%':
case '~':
case '^':
case ' ':
return true;
default:
return false;
}
}
static void replaceAll(std::string &s, const std::string &from, const std::string &to)
static inline size_t utf8CodePointLength(unsigned char lead)
{
if (from.empty())
return;
size_t pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos) {
s.replace(pos, from.size(), to);
pos += to.size();
if ((lead & 0x80) == 0x00) {
return 1;
}
if ((lead & 0xE0) == 0xC0) {
return 2;
}
if ((lead & 0xF0) == 0xE0) {
return 3;
}
if ((lead & 0xF8) == 0xF0) {
return 4;
}
return 1;
}
std::string sanitizeString(const std::string &input)
{
static constexpr char kReplacementChar = static_cast<char>(0xBF); // Inverted question mark in ISO-8859-1.
std::string output;
output.reserve(input.size());
bool inReplacement = false;
// Make a mutable copy so we can normalize UTF-8 “smart punctuation” into ASCII first.
std::string s = input;
// Curly single quotes: ‘ ’
replaceAll(s, "\xE2\x80\x98", "'"); // U+2018
replaceAll(s, "\xE2\x80\x99", "'"); // U+2019
// Curly double quotes: “ ”
replaceAll(s, "\xE2\x80\x9C", "\""); // U+201C
replaceAll(s, "\xE2\x80\x9D", "\""); // U+201D
// En dash / Em dash: – —
replaceAll(s, "\xE2\x80\x93", "-"); // U+2013
replaceAll(s, "\xE2\x80\x94", "-"); // U+2014
// Non-breaking space
replaceAll(s, "\xC2\xA0", " "); // U+00A0
// Now do your original sanitize pass over the normalized string.
for (unsigned char uc : s) {
char c = static_cast<char>(uc);
if (std::isalnum(uc) || isAllowedPunctuation(c)) {
output += c;
inReplacement = false;
} else {
const size_t inputSize = input.size();
size_t i = 0;
while (i < inputSize) {
const unsigned char byte0 = static_cast<unsigned char>(input[i]);
char normalized = '\0';
size_t consumed = 0;
if (byte0 < 0x80) {
normalized = static_cast<char>(byte0);
consumed = 1;
} else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast<unsigned char>(input[i + 1]) == 0x80) {
// Smart punctuation: ' ' \" \" - -
switch (static_cast<unsigned char>(input[i + 2])) {
case 0x98:
case 0x99:
normalized = '\'';
consumed = 3;
break;
case 0x9C:
case 0x9D:
normalized = '\"';
consumed = 3;
break;
case 0x93:
case 0x94:
normalized = '-';
consumed = 3;
break;
default:
break;
}
} else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast<unsigned char>(input[i + 1]) == 0xA0) {
// Non-breaking space.
normalized = ' ';
consumed = 2;
}
if (consumed == 0) {
size_t seqLen = utf8CodePointLength(byte0);
if (seqLen > (inputSize - i)) {
seqLen = 1;
}
if (!inReplacement) {
output += static_cast<char>(0xBF); // ISO-8859-1 for inverted question mark
output.push_back(kReplacementChar);
inReplacement = true;
}
i += seqLen;
continue;
}
const unsigned char normalizedUc = static_cast<unsigned char>(normalized);
if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) {
output.push_back(normalized);
inReplacement = false;
} else if (!inReplacement) {
output.push_back(kReplacementChar);
inReplacement = true;
}
i += consumed;
}
return output;
}
+3 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include <OLEDDisplay.h>
#include <stdint.h>
#include <string>
namespace graphics
@@ -52,7 +53,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
// Shared battery/time/mail header
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false,
bool show_date = false);
bool show_date = false, bool transparent_background = false, bool use_title_color_override = false,
uint16_t title_color_override = 0);
// Shared battery/time/mail header
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
+819
View File
@@ -0,0 +1,819 @@
#include "TFTColorRegions.h"
#include "NodeDB.h"
#include "TFTPalette.h"
#include <string.h>
namespace graphics
{
TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
namespace
{
struct TFTRoleColorsBe {
uint16_t onColorBe;
uint16_t offColorBe;
};
static uint8_t colorRegionCount = 0;
static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u;
static constexpr uint32_t kFnv1aPrime = 16777619u;
static constexpr uint16_t toBe565(uint16_t color)
{
return static_cast<uint16_t>((color >> 8) | (color << 8));
}
static constexpr bool kRoleIsBody[static_cast<size_t>(TFTColorRole::Count)] = {
false, // HeaderBackground
false, // HeaderTitle
false, // HeaderStatus
true, // SignalBars
true, // ConnectionIcon
true, // UtilizationFill
true, // FavoriteNode
true, // ActionMenuBorder
true, // ActionMenuBody
true, // ActionMenuTitle
true, // FrameMono
false, // BootSplash
true, // FavoriteNodeBGHighlight
false, // NavigationBar
false // NavigationArrow
};
static inline bool isBodyColorRole(TFTColorRole role)
{
return kRoleIsBody[static_cast<size_t>(role)];
}
static inline bool isMonochromeTheme(uint32_t themeId)
{
return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
}
static inline uint16_t getMonochromeAccent(uint32_t themeId)
{
return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen
: (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed
: TFTPalette::White;
}
static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to)
{
if (value == from) {
value = to;
}
}
static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value)
{
return (hash ^ value) * kFnv1aPrime;
}
static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value)
{
hash = fnv1aAppendByte(hash, static_cast<uint8_t>(value & 0xFF));
hash = fnv1aAppendByte(hash, static_cast<uint8_t>((value >> 8) & 0xFF));
return hash;
}
// Compile-time header color overrides (backward-compatible)
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE;
#else
static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray;
#endif
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE;
#else
static constexpr uint16_t kTitleColor = TFTPalette::White;
#endif
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE;
#else
static constexpr uint16_t kStatusColor = TFTPalette::White;
#endif
// Theme definitions
// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color
// against each entry's .uniqueIdentifier field.
static const TFTThemeDef kThemes[] = {
// Default Dark (ThemeID::DefaultDark = 0)
{
ThemeID::DefaultDark, // id
"Default Dark", // name
0, // uniqueIdentifier
// roles[TFTColorRole::Count]
{
{kHeaderBackground, TFTPalette::Black}, // HeaderBackground
{kHeaderBackground, kTitleColor}, // HeaderTitle
{kHeaderBackground, kStatusColor}, // HeaderStatus
{TFTPalette::Good, TFTPalette::Black}, // SignalBars
{TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon
{TFTPalette::Good, TFTPalette::Black}, // UtilizationFill
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle
{TFTPalette::Black, TFTPalette::White}, // FrameMono
{TFTPalette::White, TFTPalette::Black}, // BootSplash
{TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight
{kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg)
{kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
},
TFTPalette::Good, // batteryFillGood
TFTPalette::Medium, // batteryFillMedium
TFTPalette::Bad, // batteryFillBad
false, // fullFrameInvert
true, // visible
},
// Default Light (ThemeID::DefaultLight = 1)
{
ThemeID::DefaultLight, // id
"Default Light", // name
1, // uniqueIdentifier
{
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle
{TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus
{TFTPalette::Good, TFTPalette::White}, // SignalBars
{TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon
{TFTPalette::Good, TFTPalette::White}, // UtilizationFill
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode
{TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder
{TFTPalette::Black, TFTPalette::White}, // ActionMenuBody
{TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle
{TFTPalette::Black, TFTPalette::White}, // FrameMono
{TFTPalette::White, TFTPalette::Black}, // BootSplash
{TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight
{TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg)
{TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
},
TFTPalette::Good, // batteryFillGood
TFTPalette::Medium, // batteryFillMedium
TFTPalette::Bad, // batteryFillBad
true, // fullFrameInvert
true, // visible
},
// Christmas (ThemeID::Christmas = 2)
{
ThemeID::Christmas, // id
"Christmas", // name
2, // uniqueIdentifier
{
{TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle
{TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars
{TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon
{TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode
{TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder
{TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody
{TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle
{TFTPalette::Pine, TFTPalette::White}, // FrameMono
{TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash
{TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight
{TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg)
{TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg)
},
TFTPalette::ChristmasGreen, // batteryFillGood
TFTPalette::Gold, // batteryFillMedium
TFTPalette::ChristmasRed, // batteryFillBad
true, // fullFrameInvert
false, // visible
},
// Pink (ThemeID::Pink = 3) light variant
{
ThemeID::Pink, // id
"Pink", // name
3, // uniqueIdentifier
{
{TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground
{TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle
{TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus
{TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars
{TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon
{TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode
{TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder
{TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody
{TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle
{TFTPalette::Black, TFTPalette::White}, // FrameMono
{TFTPalette::White, TFTPalette::HotPink}, // BootSplash
{TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight
{TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg)
{TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg)
},
TFTPalette::DeepPink, // batteryFillGood
TFTPalette::HotPink, // batteryFillMedium
TFTPalette::Bad, // batteryFillBad
true, // fullFrameInvert
true, // visible
},
// Blue (ThemeID::Blue = 4) dark variant
{
ThemeID::Blue, // id
"Blue", // name
4, // uniqueIdentifier
{
{TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground
{TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle
{TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus
{TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars
{TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon
{TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode
{TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder
{TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody
{TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle
{TFTPalette::Navy, TFTPalette::White}, // FrameMono
{TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash
{TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight
{TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg)
{TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
},
TFTPalette::SkyBlue, // batteryFillGood
TFTPalette::Medium, // batteryFillMedium
TFTPalette::Bad, // batteryFillBad
true, // fullFrameInvert
true, // visible
},
// Creamsicle (ThemeID::Creamsicle = 5)light variant
{
ThemeID::Creamsicle, // id
"Creamsicle", // name
5, // uniqueIdentifier
{
{TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle
{TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus
{TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon
{TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode
{TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder
{TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody
{TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle
{TFTPalette::Black, TFTPalette::White}, // FrameMono
{TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash
{TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight
{TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg)
{TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
},
TFTPalette::DeepOrange, // batteryFillGood
TFTPalette::Gold, // batteryFillMedium
TFTPalette::Bad, // batteryFillBad
true, // fullFrameInvert
true, // visible
},
// Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome
// Pure single-color-on-black look. Every role maps foreground pixels to
// the theme color and background pixels to Black.
{
ThemeID::MeshtasticGreen, // id
"Meshtastic Green", // name
6, // uniqueIdentifier
{
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle
{TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg)
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar
{TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow
},
TFTPalette::Black, // batteryFillGood
TFTPalette::Black, // batteryFillMedium
TFTPalette::Black, // batteryFillBad
true, // fullFrameInvert
true, // visible
},
// Classic Red (ThemeID::ClassicRed = 7) classic monochrome
{
ThemeID::ClassicRed, // id
"Classic Red", // name
7, // uniqueIdentifier
{
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle
{TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus
{TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars
{TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon
{TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody
{TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle
{TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg)
{TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash
{TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar
{TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow
},
TFTPalette::Black, // batteryFillGood
TFTPalette::Black, // batteryFillMedium
TFTPalette::Black, // batteryFillBad
true, // fullFrameInvert
true, // visible
},
// Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome
{
ThemeID::MonochromeWhite, // id
"Monochrome White", // name
8, // uniqueIdentifier
{
{TFTPalette::White, TFTPalette::Black}, // HeaderBackground
{TFTPalette::White, TFTPalette::Black}, // HeaderTitle
{TFTPalette::White, TFTPalette::Black}, // HeaderStatus
{TFTPalette::White, TFTPalette::Black}, // SignalBars
{TFTPalette::White, TFTPalette::Black}, // ConnectionIcon
{TFTPalette::White, TFTPalette::Black}, // UtilizationFill
{TFTPalette::White, TFTPalette::Black}, // FavoriteNode
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder
{TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
{TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle
{TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg)
{TFTPalette::White, TFTPalette::Black}, // BootSplash
{TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight
{TFTPalette::White, TFTPalette::Black}, // NavigationBar
{TFTPalette::White, TFTPalette::Black}, // NavigationArrow
},
TFTPalette::Black, // batteryFillGood
TFTPalette::Black, // batteryFillMedium
TFTPalette::Black, // batteryFillBad
true, // fullFrameInvert
true, // visible
},
};
static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]);
// Resolve the kThemes[] index for the currently persisted theme. Called at
// boot (indirectly via getActiveTheme()) and whenever the active theme is
// queried, so uiconfig.screen_rgb_color remains the single source of truth.
// Matches against .uniqueIdentifier - that's the field whose value is stored
// in the user's config. Falls back to 0 (DefaultDark) if no match is found,
// which gracefully handles removed or retired themes.
static inline size_t resolveThemeIndex()
{
const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F;
for (size_t i = 0; i < kInternalThemeCount; i++) {
if (kThemes[i].uniqueIdentifier == savedIdentifier)
return i;
}
return 0; // Default Dark fallback
}
static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height)
{
if (width <= 0 || height <= 0) {
return false;
}
if (x < 0) {
width += x;
x = 0;
}
if (y < 0) {
height += y;
y = 0;
}
return width > 0 && height > 0;
}
static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe)
{
// Keep the last slot permanently disabled as a sentinel for ST7789 scans.
// This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries.
if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) {
memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2));
colorRegionCount = MAX_TFT_COLOR_REGIONS - 2;
}
TFTColorRegion &region = colorRegions[colorRegionCount++];
region.x = x;
region.y = y;
region.width = width;
region.height = height;
region.onColorBe = onColorBe;
region.offColorBe = offColorBe;
region.enabled = true;
// Keep one disabled sentinel after the active range for ST7789 countColorRegions().
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
colorRegions[colorRegionCount].enabled = false;
}
colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false;
}
// Current working role colors (big-endian). Initialised to Dark defaults;
// call loadThemeDefaults() after boot / theme change to refresh.
static TFTRoleColorsBe roleColors[static_cast<size_t>(TFTColorRole::Count)] = {
{toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground
{toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle
{toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars
{toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon
{toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody
{toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle
{toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono
{toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash
{toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight
{toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar
{toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow
};
} // namespace
// Theme accessors
const TFTThemeDef &getActiveTheme()
{
return kThemes[resolveThemeIndex()];
}
// Visible-theme accessors
// These iterate only themes flagged .visible = true, preserving kThemes[]
// order. Menu code should use these so hidden themes don't appear in the
// picker while still applying correctly if their ID is persisted.
size_t getVisibleThemeCount()
{
size_t count = 0;
for (size_t i = 0; i < kInternalThemeCount; i++) {
if (kThemes[i].visible)
count++;
}
return count;
}
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex)
{
size_t seen = 0;
for (size_t i = 0; i < kInternalThemeCount; i++) {
if (!kThemes[i].visible)
continue;
if (seen == visibleIndex)
return kThemes[i];
seen++;
}
// Fallback: return first theme (never trust a bad index).
return kThemes[0];
}
size_t getActiveVisibleThemeIndex()
{
const size_t active = resolveThemeIndex();
if (!kThemes[active].visible)
return SIZE_MAX;
size_t visibleIdx = 0;
for (size_t i = 0; i < active; i++) {
if (kThemes[i].visible)
visibleIdx++;
}
return visibleIdx;
}
uint16_t getThemeHeaderBg()
{
#if GRAPHICS_TFT_COLORING_ENABLED
#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
return TFT_HEADER_BG_COLOR_OVERRIDE;
#else
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderBackground)].onColor;
#endif
#else
return TFTPalette::DarkGray;
#endif
}
uint16_t getThemeHeaderText()
{
#if GRAPHICS_TFT_COLORING_ENABLED
#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
return TFT_HEADER_TITLE_COLOR_OVERRIDE;
#else
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderTitle)].offColor;
#endif
#else
return TFTPalette::White;
#endif
}
uint16_t getThemeHeaderStatus()
{
#if GRAPHICS_TFT_COLORING_ENABLED
#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
return TFT_HEADER_STATUS_COLOR_OVERRIDE;
#else
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::HeaderStatus)].offColor;
#endif
#else
return TFTPalette::White;
#endif
}
uint16_t getThemeBodyBg()
{
#if GRAPHICS_TFT_COLORING_ENABLED
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].onColor;
#else
return TFTPalette::Black;
#endif
}
uint16_t getThemeBodyFg()
{
#if GRAPHICS_TFT_COLORING_ENABLED
return kThemes[resolveThemeIndex()].roles[static_cast<size_t>(TFTColorRole::FrameMono)].offColor;
#else
return TFTPalette::White;
#endif
}
bool isThemeFullFrameInvert()
{
#if GRAPHICS_TFT_COLORING_ENABLED
return kThemes[resolveThemeIndex()].fullFrameInvert;
#else
return false;
#endif
}
uint16_t getThemeBatteryFillColor(int batteryPercent)
{
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
if (batteryPercent <= 20) {
return theme.batteryFillBad;
}
if (batteryPercent <= 50) {
return theme.batteryFillMedium;
}
return theme.batteryFillGood;
}
void loadThemeDefaults()
{
#if GRAPHICS_TFT_COLORING_ENABLED
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
for (uint8_t i = 0; i < static_cast<uint8_t>(TFTColorRole::Count); i++) {
roleColors[i].onColorBe = toBe565(theme.roles[i].onColor);
roleColors[i].offColorBe = toBe565(theme.roles[i].offColor);
}
#endif
}
// Role color assignment with theme-aware transforms
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return;
#endif
const uint8_t index = static_cast<uint8_t>(role);
if (index >= static_cast<uint8_t>(TFTColorRole::Count)) {
return;
}
const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F;
const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight);
const bool isBodyRole = !isHighlightRole && isBodyColorRole(role);
// Classic monochrome themes collapse all non-black accents into one tone.
if (isMonochromeTheme(themeId)) {
if (onColor != TFTPalette::Black) {
onColor = getMonochromeAccent(themeId);
}
} else {
switch (themeId) {
case ThemeID::DefaultLight:
if (isHighlightRole) {
// High-contrast highlight chips on light UI.
onColor = TFTPalette::Black;
offColor = TFTPalette::Yellow;
} else if (isBodyRole) {
// Invert body colors for readability on white frames.
if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) {
offColor = TFTPalette::White;
}
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
}
break;
case ThemeID::Christmas:
if (isHighlightRole || isBodyRole) {
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold);
replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine);
}
break;
case ThemeID::Pink:
if (isHighlightRole) {
onColor = TFTPalette::Black;
offColor = TFTPalette::HotPink;
} else if (isBodyRole) {
replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink);
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink);
}
break;
case ThemeID::Creamsicle:
if (isHighlightRole) {
onColor = TFTPalette::Black;
offColor = TFTPalette::CreamOrange;
} else if (isBodyRole) {
replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream);
replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange);
}
break;
case ThemeID::Blue:
if (isHighlightRole || isBodyRole) {
replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue);
replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy);
}
break;
default:
break;
}
}
roleColors[index].onColorBe = toBe565(onColor);
roleColors[index].offColorBe = toBe565(offColor);
}
// Region registration
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return;
#endif
const uint8_t roleIndex = static_cast<uint8_t>(role);
if (roleIndex >= static_cast<uint8_t>(TFTColorRole::Count)) {
return;
}
if (!normalizeRegion(x, y, width, height)) {
return;
}
const TFTRoleColorsBe &colors = roleColors[roleIndex];
appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe);
}
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
int16_t height)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
(void)role;
(void)onColor;
(void)offColor;
(void)x;
(void)y;
(void)width;
(void)height;
return;
#else
setTFTColorRole(role, onColor, offColor);
registerTFTColorRegion(role, x, y, width, height);
#endif
}
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return;
#endif
if (!normalizeRegion(x, y, width, height))
return;
appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor));
}
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
(void)boxLeft;
(void)boxTop;
(void)boxWidth;
(void)boxHeight;
return;
#else
// Use theme-appropriate menu colors.
const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
const TFTThemeRoleColor &menuBody = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBody)];
const TFTThemeRoleColor &menuBorder = theme.roles[static_cast<size_t>(TFTColorRole::ActionMenuBorder)];
// Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly.
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1,
boxWidth + 2, boxHeight + 2);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight);
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth,
1);
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1);
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight);
registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight);
#endif
}
// Frame signature & utilities
uint32_t getTFTColorFrameSignature()
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return 0;
#else
uint32_t hash = kFnv1aOffsetBasis;
hash = fnv1aAppendByte(hash, colorRegionCount);
for (uint8_t i = 0; i < colorRegionCount; i++) {
const TFTColorRegion &r = colorRegions[i];
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.x));
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.y));
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.width));
hash = fnv1aAppendU16(hash, static_cast<uint16_t>(r.height));
hash = fnv1aAppendU16(hash, r.onColorBe);
hash = fnv1aAppendU16(hash, r.offColorBe);
}
return hash;
#endif
}
uint8_t getTFTColorRegionCount()
{
#if !GRAPHICS_TFT_COLORING_ENABLED
return 0;
#else
return colorRegionCount;
#endif
}
void clearTFTColorRegions()
{
for (uint8_t i = 0; i < colorRegionCount; i++) {
colorRegions[i].enabled = false;
}
if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
colorRegions[colorRegionCount].enabled = false;
}
colorRegionCount = 0;
}
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
{
for (int i = static_cast<int>(colorRegionCount) - 1; i >= 0; i--) {
const TFTColorRegion &r = colorRegions[i];
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
return isset ? r.onColorBe : r.offColorBe;
}
}
return isset ? defaultOnColor : defaultOffColor;
}
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor)
{
#if !GRAPHICS_TFT_COLORING_ENABLED
(void)x;
(void)y;
return defaultOffColor;
#else
const uint16_t defaultOffBe = toBe565(defaultOffColor);
const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe);
return static_cast<uint16_t>((sampledBe >> 8) | (sampledBe << 8));
#endif
}
} // namespace graphics
+163
View File
@@ -0,0 +1,163 @@
#pragma once
#include "configuration.h"
#include <stdint.h>
namespace graphics
{
struct TFTColorRegion {
int16_t x;
int16_t y;
int16_t width;
int16_t height;
uint16_t onColorBe;
uint16_t offColorBe;
// Required by ST7789 driver: it scans until the first disabled entry.
bool enabled = false;
};
static constexpr size_t MAX_TFT_COLOR_REGIONS = 48;
extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
enum class TFTColorRole : uint8_t {
HeaderBackground = 0,
HeaderTitle,
HeaderStatus,
SignalBars,
ConnectionIcon,
UtilizationFill,
FavoriteNode,
ActionMenuBorder,
ActionMenuBody,
ActionMenuTitle,
FrameMono,
BootSplash,
FavoriteNodeBGHighlight,
NavigationBar,
NavigationArrow,
Count
};
#if HAS_TFT || defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \
defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) || \
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)
#define GRAPHICS_TFT_COLORING_ENABLED 1
#else
#define GRAPHICS_TFT_COLORING_ENABLED 0
#endif
static constexpr bool kTFTColoringEnabled = GRAPHICS_TFT_COLORING_ENABLED != 0;
constexpr bool isTFTColoringEnabled()
{
return kTFTColoringEnabled;
}
void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor);
void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height);
// Convenience helper for the common "set role then register one region" flow.
void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
int16_t height);
// Register a region using explicit colors (no role lookup). Use when the
// color comes from a theme field rather than a role (e.g. battery fill).
void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor);
void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight);
uint32_t getTFTColorFrameSignature();
uint8_t getTFTColorRegionCount();
void clearTFTColorRegions();
uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor);
// Resolve effective region-mapped OFF color at a coordinate in native-endian RGB565.
uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor);
// -- Theme engine ------------------------------------------------------
// Each theme has four fields that work together:
//
// id - ThemeID:: constant, used for in-code references.
// name - human-readable label shown in the theme picker.
// uniqueIdentifier - the stable numeric value persisted to
// uiconfig.screen_rgb_color and restored at boot.
// This is a CONTRACT with saved configs on disk - once
// assigned, never reuse or renumber, even if the theme is
// deleted or the kThemes[] array is reordered.
// visible - controls whether a theme appears in the picker menu.
// Hidden themes can still be restored and applied if their
// uniqueIdentifier is persisted.
//
// Display order in the menu is controlled by kThemes[] array order among
// themes where visible == true, NOT by any numeric value above.
//
// To add a new theme:
// 1. Add a unique constant in ThemeID below (next unused value).
// 2. Add a kThemes[] entry at the desired menu position, with a unique
// uniqueIdentifier that has never been used by any prior theme.
// 3. Set visible=true if it should appear in the picker.
//
// To retire a theme without breaking saved configs:
// - Preferred: keep the entry and set visible=false so existing saved
// uniqueIdentifier values still resolve to the same theme.
// - If you remove the entry, resolveThemeIndex() falls back to DefaultDark
// when the persisted uniqueIdentifier no longer matches any theme.
// - Do NOT reuse a retired uniqueIdentifier for a future theme.
namespace ThemeID
{
constexpr uint32_t DefaultDark = 0;
constexpr uint32_t DefaultLight = 1;
constexpr uint32_t Christmas = 2;
constexpr uint32_t Pink = 3;
constexpr uint32_t Blue = 4;
constexpr uint32_t Creamsicle = 5;
constexpr uint32_t MeshtasticGreen = 6;
constexpr uint32_t ClassicRed = 7;
constexpr uint32_t MonochromeWhite = 8;
} // namespace ThemeID
// Per-role color pair stored in native (little-endian) RGB565 format.
struct TFTThemeRoleColor {
uint16_t onColor;
uint16_t offColor;
};
// Complete theme definition.
struct TFTThemeDef {
uint32_t id; // ThemeID constant - in-code identifier for this theme.
const char *name; // Human-readable label shown in the theme picker.
uint32_t uniqueIdentifier; // Stable persisted value copied into uiconfig.screen_rgb_color.
// Never reuse or renumber - see file-level notes above.
TFTThemeRoleColor roles[static_cast<size_t>(TFTColorRole::Count)];
uint16_t batteryFillGood;
uint16_t batteryFillMedium;
uint16_t batteryFillBad;
bool fullFrameInvert; // Apply full-frame FrameMono inversion (ST7789 light themes)
bool visible; // Show in the theme picker menu. Hidden themes still apply
// correctly if their uniqueIdentifier is persisted (dev/legacy themes).
};
// Count of themes whose .visible flag is true. Use this when building menus.
size_t getVisibleThemeCount();
// Access the Nth visible theme (0 .. getVisibleThemeCount()-1). Hidden themes
// are skipped, preserving kThemes[] order among the visible entries.
const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex);
// Return the theme that matches uiconfig.screen_rgb_color (falls back to Dark).
const TFTThemeDef &getActiveTheme();
// Return the visible-theme index for the currently active theme, or SIZE_MAX
// if the active theme is hidden (so menus can show "no selection").
size_t getActiveVisibleThemeIndex();
// Convenience accessors - safe to call even when coloring is compiled out.
uint16_t getThemeHeaderBg();
uint16_t getThemeHeaderText();
uint16_t getThemeHeaderStatus();
uint16_t getThemeBodyBg();
uint16_t getThemeBodyFg();
bool isThemeFullFrameInvert();
uint16_t getThemeBatteryFillColor(int batteryPercent);
// Reinitialise default roleColors from the active theme. Call after a
// theme change so that any role registered without a prior setTFTColorRole()
// picks up theme-appropriate defaults.
void loadThemeDefaults();
} // namespace graphics
+142 -43
View File
@@ -16,12 +16,6 @@
extern SX1509 gpioExtender;
#endif
#ifdef TFT_MESH_OVERRIDE
uint16_t TFT_MESH = TFT_MESH_OVERRIDE;
#else
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
#endif
#if defined(ST7735S)
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
@@ -1140,7 +1134,9 @@ static LGFX *tft = nullptr;
#endif
#include "SPILock.h"
#include "TFTColorRegions.h"
#include "TFTDisplay.h"
#include "TFTPalette.h"
#include <SPI.h>
#ifdef UNPHONE
@@ -1150,6 +1146,25 @@ extern unPhone unphone;
GpioPin *TFTDisplay::backlightEnable = NULL;
namespace
{
static constexpr uint8_t kFullRepaintChunkRows = 8;
static inline uint16_t getThemeDefaultOnColor()
{
return graphics::TFTPalette::White;
}
static inline uint16_t getThemeDefaultOffColor()
{
#if GRAPHICS_TFT_COLORING_ENABLED
return graphics::getThemeBodyBg();
#else
return TFT_BLACK;
#endif
}
} // namespace
TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
{
LOG_DEBUG("TFTDisplay!");
@@ -1189,14 +1204,15 @@ TFTDisplay::~TFTDisplay()
free(linePixelBuffer);
linePixelBuffer = nullptr;
}
if (repaintChunkBuffer != nullptr) {
free(repaintChunkBuffer);
repaintChunkBuffer = nullptr;
}
}
// Write the buffer to the display memory
void TFTDisplay::display(bool fromBlank)
{
if (fromBlank)
tft->fillScreen(TFT_BLACK);
concurrency::LockGuard g(spiLock);
uint32_t x, y;
@@ -1205,12 +1221,70 @@ void TFTDisplay::display(bool fromBlank)
uint32_t x_FirstPixelUpdate;
uint32_t x_LastPixelUpdate;
bool isset, dblbuf_isset;
uint16_t colorTftMesh, colorTftBlack;
uint16_t colorTftWhite, colorTftBlack;
bool somethingChanged = false;
// Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step
colorTftMesh = __builtin_bswap16(TFT_MESH);
colorTftBlack = __builtin_bswap16(TFT_BLACK);
// Theme defaults for non-role pixels.
const uint16_t defaultOnColor = getThemeDefaultOnColor();
const uint16_t defaultOffColor = getThemeDefaultOffColor();
static uint16_t lastDefaultOnColor = 0;
static uint16_t lastDefaultOffColor = 0;
static bool haveLastDefaults = false;
const bool themeDefaultsChanged =
!haveLastDefaults || (defaultOnColor != lastDefaultOnColor) || (defaultOffColor != lastDefaultOffColor);
const bool forceFullRepaint = fromBlank || themeDefaultsChanged;
// If theme defaults changed, reset panel background immediately so stale pixels don't linger.
if (forceFullRepaint) {
tft->fillScreen(defaultOffColor);
}
colorTftWhite = (defaultOnColor >> 8) | ((defaultOnColor & 0xFF) << 8);
colorTftBlack = (defaultOffColor >> 8) | ((defaultOffColor & 0xFF) << 8);
#if GRAPHICS_TFT_COLORING_ENABLED
static uint32_t lastColorFrameSignature = 0;
const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
const uint32_t colorFrameSignature = graphics::getTFTColorFrameSignature();
const bool forceFullColorRepaint = forceFullRepaint || (colorFrameSignature != lastColorFrameSignature);
// When region roles/layout changed, color can differ even with identical monochrome glyph bits.
// Repaint full frame only for those frames, then return to diff-based updates.
if (forceFullColorRepaint) {
for (uint32_t yStart = 0; yStart < displayHeight; yStart += kFullRepaintChunkRows) {
const uint32_t rowsThisChunk = min<uint32_t>(kFullRepaintChunkRows, displayHeight - yStart);
for (uint32_t row = 0; row < rowsThisChunk; row++) {
y = yStart + row;
y_byteIndex = (y / 8) * displayWidth;
y_byteMask = (1 << (y & 7));
uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth);
for (x = 0; x < displayWidth; x++) {
isset = (buffer[x + y_byteIndex] & y_byteMask) != 0;
if (hasColorRegions) {
chunkRow[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
colorTftWhite, colorTftBlack);
} else {
chunkRow[x] = isset ? colorTftWhite : colorTftBlack;
}
}
}
#if defined(HACKADAY_COMMUNICATOR)
tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk);
#else
tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer);
#endif
}
memcpy(buffer_back, buffer, displayBufferSize);
lastColorFrameSignature = colorFrameSignature;
haveLastDefaults = true;
lastDefaultOnColor = defaultOnColor;
lastDefaultOffColor = defaultOffColor;
graphics::clearTFTColorRegions();
return;
}
#endif
y = 0;
while (y < displayHeight) {
@@ -1219,7 +1293,7 @@ void TFTDisplay::display(bool fromBlank)
// Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas.
if (y_byteMask == 1) {
if (!fromBlank) {
if (!forceFullRepaint) {
for (x = 0; x < displayWidth; x++) {
if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex])
break;
@@ -1237,13 +1311,14 @@ void TFTDisplay::display(bool fromBlank)
}
}
// Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating
for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) {
isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
// Step 2: Scan this row for changed span (first and last changed pixel).
uint32_t x_FirstChanged = 0;
for (x_FirstChanged = 0; x_FirstChanged < displayWidth; x_FirstChanged++) {
isset = buffer[x_FirstChanged + y_byteIndex] & y_byteMask;
if (!fromBlank) {
if (!forceFullRepaint) {
// get src pixel in the page based ordering the OLED lib uses
dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask;
if (isset != dblbuf_isset) {
break;
}
@@ -1253,43 +1328,51 @@ void TFTDisplay::display(bool fromBlank)
}
// Did we find a pixel that needs updating on this row?
if (x_FirstPixelUpdate < displayWidth) {
// Align the first pixel for update to an even number so the total alignment of
// the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
x_FirstPixelUpdate &= ~1;
// Step 3a: copy rest of the pixels in this row into the pixel line buffer,
// while also recording the last pixel in the row that needs updating.
// Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set.
for (x = x_FirstPixelUpdate; x < displayWidth; x++) {
isset = buffer[x + y_byteIndex] & y_byteMask;
linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack;
if (!fromBlank) {
dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask;
if (x_FirstChanged < displayWidth) {
uint32_t x_LastChanged = displayWidth - 1;
while (x_LastChanged > x_FirstChanged) {
isset = buffer[x_LastChanged + y_byteIndex] & y_byteMask;
if (!forceFullRepaint) {
dblbuf_isset = buffer_back[x_LastChanged + y_byteIndex] & y_byteMask;
if (isset != dblbuf_isset) {
x_LastPixelUpdate = x;
break;
}
} else if (isset) {
x_LastPixelUpdate = x;
break;
}
x_LastChanged--;
}
// Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs.
// Most displays will have even number of pixels in a row -- this will be in bounds
// of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.)
x_LastPixelUpdate |= 1;
// Ensure the last pixel index does not exceed the display width.
// Align the first pixel for update to an even number so the total alignment of
// the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
x_FirstPixelUpdate = x_FirstChanged & ~1U;
x_LastPixelUpdate = x_LastChanged | 1U;
if (x_LastPixelUpdate >= displayWidth) {
x_LastPixelUpdate = displayWidth - 1;
}
// Step 3: Copy only the changed span into the pixel line buffer.
for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
isset = buffer[x + y_byteIndex] & y_byteMask;
#if GRAPHICS_TFT_COLORING_ENABLED
if (hasColorRegions) {
linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y), isset,
colorTftWhite, colorTftBlack);
} else {
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
}
#else
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
#endif
}
#if defined(HACKADAY_COMMUNICATOR)
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
(x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1);
#else
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
&linePixelBuffer[x_FirstPixelUpdate]);
tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
&linePixelBuffer[x_FirstPixelUpdate]);
#endif
somethingChanged = true;
}
@@ -1298,6 +1381,14 @@ void TFTDisplay::display(bool fromBlank)
// Copy the Buffer to the Back Buffer
if (somethingChanged)
memcpy(buffer_back, buffer, displayBufferSize);
#if GRAPHICS_TFT_COLORING_ENABLED
lastColorFrameSignature = colorFrameSignature;
#endif
haveLastDefaults = true;
lastDefaultOnColor = defaultOnColor;
lastDefaultOffColor = defaultOffColor;
graphics::clearTFTColorRegions();
}
void TFTDisplay::sdlLoop()
@@ -1511,7 +1602,7 @@ bool TFTDisplay::connect()
#else
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
#endif
tft->fillScreen(TFT_BLACK);
tft->fillScreen(getThemeDefaultOffColor());
if (this->linePixelBuffer == NULL) {
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
@@ -1521,6 +1612,14 @@ bool TFTDisplay::connect()
return false;
}
}
if (this->repaintChunkBuffer == NULL) {
this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows);
if (!this->repaintChunkBuffer) {
LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n");
return false;
}
}
return true;
}
+2 -1
View File
@@ -63,4 +63,5 @@ class TFTDisplay : public OLEDDisplay
virtual bool connect() override;
uint16_t *linePixelBuffer = nullptr;
};
uint16_t *repaintChunkBuffer = nullptr;
};
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <stdint.h>
namespace graphics
{
namespace TFTPalette
{
constexpr uint16_t rgb565(uint8_t red, uint8_t green, uint8_t blue)
{
return static_cast<uint16_t>(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | ((blue & 0xF8) >> 3));
}
constexpr uint16_t Black = 0x0000;
constexpr uint16_t White = 0xFFFF;
constexpr uint16_t DarkGray = 0x4208;
constexpr uint16_t Gray = 0x8410;
constexpr uint16_t LightGray = 0xC618;
constexpr uint16_t Red = rgb565(255, 0, 0);
constexpr uint16_t Green = rgb565(0, 255, 0);
constexpr uint16_t Blue = rgb565(0, 130, 252);
constexpr uint16_t Yellow = rgb565(255, 255, 0);
constexpr uint16_t Orange = rgb565(255, 165, 0);
constexpr uint16_t Cyan = rgb565(0, 255, 255);
constexpr uint16_t Magenta = rgb565(255, 0, 255);
constexpr uint16_t Good = Green;
constexpr uint16_t Medium = Yellow;
constexpr uint16_t Bad = Red;
// Christmas / seasonal accent colors
constexpr uint16_t ChristmasRed = rgb565(178, 34, 34);
constexpr uint16_t ChristmasGreen = rgb565(0, 128, 0);
constexpr uint16_t Gold = rgb565(255, 215, 0);
constexpr uint16_t Pine = rgb565(15, 35, 10);
// Pink theme colors (light variant)
constexpr uint16_t HotPink = rgb565(255, 105, 180);
constexpr uint16_t PalePink = rgb565(255, 228, 235);
constexpr uint16_t DeepPink = rgb565(200, 50, 120);
// Blue theme colors (dark variant)
constexpr uint16_t SkyBlue = rgb565(100, 180, 255);
constexpr uint16_t Navy = rgb565(15, 15, 50);
constexpr uint16_t DeepBlue = rgb565(30, 60, 120);
// Creamsicle theme colors (light variant)
constexpr uint16_t CreamOrange = rgb565(255, 140, 50);
constexpr uint16_t DeepOrange = rgb565(220, 100, 20);
constexpr uint16_t Cream = rgb565(255, 248, 235);
// Classic monochrome theme accent colors (single-color-on-black themes)
constexpr uint16_t MeshtasticGreen = rgb565(0x67, 0xEA, 0x94);
constexpr uint16_t ClassicRed = rgb565(255, 64, 64);
// Monochrome White reuses TFTPalette::White above.
// Fast contrast picker for monochrome glyph overlays on arbitrary RGB565 backgrounds.
// Uses channel-sum brightness approximation to keep code size small.
constexpr uint16_t pickReadableMonoFg(uint16_t backgroundColor)
{
const uint16_t r = (backgroundColor >> 11) & 0x1F;
const uint16_t g = (backgroundColor >> 5) & 0x3F;
const uint16_t b = backgroundColor & 0x1F;
return ((r + g + b) >= 70) ? DarkGray : White;
}
} // namespace TFTPalette
} // namespace graphics
+7 -3
View File
@@ -145,7 +145,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
// === Set Title, Blank for Clock
const char *titleStr = "";
// === Header ===
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
char timeString[16];
@@ -293,11 +293,15 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
// Draw an analog clock
void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
#if GRAPHICS_TFT_COLORING_ENABLED
// Clear previous frame pixels so moving hands don't leave stale artifacts on TFT light theme.
display->clear();
#endif
display->setTextAlignment(TEXT_ALIGN_LEFT);
// === Set Title, Blank for Clock
const char *titleStr = "";
// === Header ===
graphics::drawCommonHeader(display, x, y, titleStr, true, true);
graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
// clock face center coordinates
int16_t centerX = display->getWidth() / 2;
@@ -478,4 +482,4 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
} // namespace ClockRenderer
} // namespace graphics
#endif
#endif
+34 -86
View File
@@ -9,113 +9,61 @@ namespace graphics
{
namespace CompassRenderer
{
// Point helper class for compass calculations
struct Point {
float x, y;
Point(float x, float y) : x(x), y(y) {}
void rotate(float angle)
{
float cos_a = cosf(angle);
float sin_a = sinf(angle);
float new_x = x * cos_a - y * sin_a;
float new_y = x * sin_a + y * cos_a;
x = new_x;
y = new_y;
}
void scale(float factor)
{
x *= factor;
y *= factor;
}
void translate(float dx, float dy)
{
x += dx;
y += dy;
}
};
void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
{
// Show the compass heading (not implemented in original)
// This could draw a "N" indicator or north arrow
// For now, we'll draw a simple north indicator
// const float radius = 17.0f;
if (currentResolution == ScreenResolution::High) {
radius += 4;
}
float northX = 0.0f;
float northY = -radius;
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) {
const float c = cosf(-myHeading);
const float s = sinf(-myHeading);
const float rx = northX * c - northY * s;
const float ry = northX * s + northY * c;
northX = rx;
northY = ry;
}
northX += compassX;
northY += compassY;
const float northAngle = (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -myHeading : 0.0f;
const int16_t nX = compassX + static_cast<int16_t>((radius - 1) * sinf(northAngle));
const int16_t nY = compassY - static_cast<int16_t>((radius - 1) * cosf(northAngle));
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
#if !GRAPHICS_TFT_COLORING_ENABLED
display->setColor(BLACK);
const int16_t nLabelWidth = display->getStringWidth("N");
if (currentResolution == ScreenResolution::High) {
display->fillRect(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
display->fillRect(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
} else {
display->fillRect(northX - 4, northY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
}
display->setColor(WHITE);
display->drawString(northX, northY - 3, "N");
}
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
{
Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially
float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f;
Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY);
Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
for (int i = 0; i < 4; i++) {
arrowPoints[i]->rotate(headingRadian);
arrowPoints[i]->scale(compassDiam * 0.6);
arrowPoints[i]->translate(compassX, compassY);
}
#ifdef USE_EINK
display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
#else
display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
#endif
display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
display->setColor(WHITE);
display->drawString(nX, nY - 3, "N");
}
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
{
float radians = bearing * DEG_TO_RAD;
const float radians = bearing * DEG_TO_RAD;
const float sinA = sinf(radians);
const float cosA = cosf(radians);
const float tipHalf = size * 0.5f;
const float lx = -(size / 6.0f);
const float ly = size / 4.0f;
const float rx = (size / 6.0f);
const float ry = size / 4.0f;
const float tx = 0.0f;
const float ty = size / 4.5f;
Point tip(0, -size / 2);
Point left(-size / 6, size / 4);
Point right(size / 6, size / 4);
Point tail(0, size / 4.5);
const int16_t tipX = static_cast<int16_t>(x + (tipHalf * sinA));
const int16_t tipY = static_cast<int16_t>(y - (tipHalf * cosA));
const int16_t leftX = static_cast<int16_t>(x + (lx * cosA) - (ly * sinA));
const int16_t leftY = static_cast<int16_t>(y + (lx * sinA) + (ly * cosA));
const int16_t rightX = static_cast<int16_t>(x + (rx * cosA) - (ry * sinA));
const int16_t rightY = static_cast<int16_t>(y + (rx * sinA) + (ry * cosA));
const int16_t tailX = static_cast<int16_t>(x + (tx * cosA) - (ty * sinA));
const int16_t tailY = static_cast<int16_t>(y + (tx * sinA) + (ty * cosA));
tip.rotate(radians);
left.rotate(radians);
right.rotate(radians);
tail.rotate(radians);
display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY);
display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY);
}
tip.translate(x, y);
left.translate(x, y);
right.translate(x, y);
tail.translate(x, y);
display->fillTriangle(tip.x, tip.y, left.x, left.y, tail.x, tail.y);
display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y);
void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
{
const int16_t size = static_cast<int16_t>(compassDiam * 0.6f);
drawArrowToNode(display, compassX, compassY, size, headingRadian * RAD_TO_DEG);
}
bool getHeadingRadians(double lat, double lon, float &headingRadian)
+1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "graphics/Screen.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
+30 -5
View File
@@ -11,6 +11,8 @@
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/TimeFormatters.h"
#include "graphics/images.h"
#include "main.h"
@@ -469,9 +471,11 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
int chUtil_y = getTextPositions(display)[line] + 3;
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
int chutil_percent = airTime->channelUtilizationPercent();
const int raw_chutil_percent = chutil_percent;
int centerofscreen = SCREEN_WIDTH / 2;
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
@@ -479,7 +483,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
// Force 56% or higher to show a full 100% bar, text would still show related percent.
// Force 61% or higher to show a full 100% bar, text would still show related percent.
if (chutil_percent >= 61) {
chutil_percent = 100;
}
@@ -492,9 +496,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
float weight3 = 0.20; // Weight for 40–100%
float totalWeight = weight1 + weight2 + weight3;
int seg1 = chutil_bar_width * (weight1 / totalWeight);
int seg2 = chutil_bar_width * (weight2 / totalWeight);
int seg3 = chutil_bar_width * (weight3 / totalWeight);
int seg1 = chutil_bar_max_fill * (weight1 / totalWeight);
int seg2 = chutil_bar_max_fill * (weight2 / totalWeight);
int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors
int fillRight = 0;
@@ -511,7 +515,17 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
// Fill progress
if (fillRight > 0) {
display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
#if GRAPHICS_TFT_COLORING_ENABLED
uint16_t UtilizationFillColor = TFTPalette::Good;
if (raw_chutil_percent >= 60) {
UtilizationFillColor = TFTPalette::Bad;
} else if (raw_chutil_percent >= 35) {
UtilizationFillColor = TFTPalette::Medium;
}
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black,
starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
#endif
display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
}
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++],
@@ -584,6 +598,17 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
display->setColor(WHITE);
display->drawRect(barX, barY, adjustedBarWidth, barHeight);
#if GRAPHICS_TFT_COLORING_ENABLED
uint16_t UtilizationFillColor = TFTPalette::Good;
if (percent >= 80) {
UtilizationFillColor = TFTPalette::Bad;
} else if (percent >= 60) {
UtilizationFillColor = TFTPalette::Medium;
}
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, barX + 1, barY + 1,
fillWidth - 1, barHeight - 2);
#endif
display->fillRect(barX, barY, fillWidth, barHeight);
display->setColor(WHITE);
#endif
+63 -122
View File
@@ -11,6 +11,7 @@
#include "buzz.h"
#include "graphics/Screen.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/draw/MessageRenderer.h"
#include "graphics/draw/UIRenderer.h"
#include "input/RotaryEncoderInterruptImpl1.h"
@@ -30,8 +31,6 @@
#include <functional>
#include <utility>
extern uint16_t TFT_MESH;
namespace graphics
{
@@ -2028,109 +2027,6 @@ void menuHandler::switchToMUIMenu()
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::TFTColorPickerMenu(OLEDDisplay *display)
{
static const ScreenColorOption colorOptions[] = {
{"Back", OptionsAction::Back},
{"Default", OptionsAction::Select, ScreenColor(0, 0, 0, true)},
{"Meshtastic Green", OptionsAction::Select, ScreenColor(0x67, 0xEA, 0x94)},
{"Yellow", OptionsAction::Select, ScreenColor(255, 255, 128)},
{"Red", OptionsAction::Select, ScreenColor(255, 64, 64)},
{"Orange", OptionsAction::Select, ScreenColor(255, 160, 20)},
{"Purple", OptionsAction::Select, ScreenColor(204, 153, 255)},
{"Blue", OptionsAction::Select, ScreenColor(0, 0, 255)},
{"Teal", OptionsAction::Select, ScreenColor(16, 102, 102)},
{"Cyan", OptionsAction::Select, ScreenColor(0, 255, 255)},
{"Ice", OptionsAction::Select, ScreenColor(173, 216, 230)},
{"Pink", OptionsAction::Select, ScreenColor(255, 105, 180)},
{"White", OptionsAction::Select, ScreenColor(255, 255, 255)},
{"Gray", OptionsAction::Select, ScreenColor(128, 128, 128)},
};
constexpr size_t colorCount = sizeof(colorOptions) / sizeof(colorOptions[0]);
static std::array<const char *, colorCount> colorLabels{};
auto bannerOptions = createStaticBannerOptions(
"Select Screen Color", colorOptions, colorLabels, [display](const ScreenColorOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuQueue = SystemBaseMenu;
screen->runNow();
return;
}
if (!option.hasValue) {
return;
}
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
HAS_TFT || defined(HACKADAY_COMMUNICATOR)
const ScreenColor &color = option.value;
if (color.useVariant) {
LOG_INFO("Setting color to system default or defined variant");
} else {
LOG_INFO("Setting color to %s", option.label);
}
uint8_t r = color.r;
uint8_t g = color.g;
uint8_t b = color.b;
display->setColor(BLACK);
display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
display->setColor(WHITE);
if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
#ifdef TFT_MESH_OVERRIDE
TFT_MESH = TFT_MESH_OVERRIDE;
#else
TFT_MESH = COLOR565(255, 255, 128);
#endif
} else {
TFT_MESH = COLOR565(r, g, b);
}
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190)
static_cast<ST7789Spi *>(screen->getDisplayDevice())->setRGB(TFT_MESH);
#endif
screen->setFrames(graphics::Screen::FOCUS_SYSTEM);
if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
uiconfig.screen_rgb_color = 0;
} else {
uiconfig.screen_rgb_color =
(static_cast<uint32_t>(r) << 16) | (static_cast<uint32_t>(g) << 8) | static_cast<uint32_t>(b);
}
LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color);
saveUIConfig();
#endif
});
int initialSelection = 0;
if (uiconfig.screen_rgb_color == 0) {
initialSelection = 1;
} else {
uint32_t currentColor = uiconfig.screen_rgb_color;
for (size_t i = 0; i < colorCount; ++i) {
if (!colorOptions[i].hasValue) {
continue;
}
const ScreenColor &color = colorOptions[i].value;
if (color.useVariant) {
continue;
}
uint32_t encoded =
(static_cast<uint32_t>(color.r) << 16) | (static_cast<uint32_t>(color.g) << 8) | static_cast<uint32_t>(color.b);
if (encoded == currentColor) {
initialSelection = static_cast<int>(i);
break;
}
}
}
bannerOptions.InitialSelected = initialSelection;
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::rebootMenu()
{
static const char *optionsArray[] = {"Back", "Confirm"};
@@ -2318,9 +2214,9 @@ void menuHandler::screenOptionsMenu()
bool hasSupportBrightness = false;
#endif
enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles };
static const char *optionsArray[6] = {"Back"};
static int optionsEnumArray[6] = {Back};
enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme };
static const char *optionsArray[7] = {"Back"};
static int optionsEnumArray[7] = {Back};
int options = 1;
// Only show brightness for B&W displays
@@ -2329,13 +2225,6 @@ void menuHandler::screenOptionsMenu()
optionsEnumArray[options++] = Brightness;
}
// Only show screen color for TFT displays
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
HAS_TFT || defined(HACKADAY_COMMUNICATOR)
optionsArray[options] = "Screen Color";
optionsEnumArray[options++] = ScreenColor;
#endif
optionsArray[options] = "Frame Visibility";
optionsEnumArray[options++] = FrameToggles;
@@ -2345,6 +2234,11 @@ void menuHandler::screenOptionsMenu()
optionsArray[options] = "Message Bubbles";
optionsEnumArray[options++] = MessageBubbles;
#if GRAPHICS_TFT_COLORING_ENABLED
optionsArray[options] = "Theme";
optionsEnumArray[options++] = Theme;
#endif
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Display Options";
bannerOptions.optionsArrayPtr = optionsArray;
@@ -2354,9 +2248,6 @@ void menuHandler::screenOptionsMenu()
if (selected == Brightness) {
menuHandler::menuQueue = menuHandler::BrightnessPicker;
screen->runNow();
} else if (selected == ScreenColor) {
menuHandler::menuQueue = menuHandler::TftColorMenuPicker;
screen->runNow();
} else if (selected == FrameToggles) {
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
@@ -2366,6 +2257,9 @@ void menuHandler::screenOptionsMenu()
} else if (selected == MessageBubbles) {
menuHandler::menuQueue = menuHandler::MessageBubblesMenu;
screen->runNow();
} else if (selected == Theme) {
menuHandler::menuQueue = menuHandler::ThemeMenu;
screen->runNow();
} else {
menuQueue = SystemBaseMenu;
screen->runNow();
@@ -2649,6 +2543,53 @@ void menuHandler::messageBubblesMenu()
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::themeMenu()
{
// Build menu dynamically from the theme table.
// Only visible themes appear!
// Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes.
// Bump kMaxThemesInMenu if you add more themes than will fit here.
constexpr size_t kMaxThemesInMenu = 15;
const size_t visibleCount = getVisibleThemeCount();
static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"};
const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu;
const int options = static_cast<int>(shownCount) + 1; // +1 for Back
for (size_t i = 0; i < shownCount; i++) {
optionsArray[i + 1] = getVisibleThemeByIndex(i).name;
}
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Theme";
bannerOptions.optionsArrayPtr = optionsArray;
bannerOptions.optionsCount = options;
// Highlight the currently active theme (visible index + 1 for the Back
// offset). If the active theme is hidden, leave selection on "Back".
const size_t activeVisible = getActiveVisibleThemeIndex();
bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast<int>(activeVisible) + 1;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == 0) {
// Back
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
screen->runNow();
} else {
// Selection is an index into the VISIBLE themes (1-based, slot 0 is Back).
const size_t visibleIdx = static_cast<size_t>(selected - 1);
if (visibleIdx < getVisibleThemeCount()) {
// Persist the theme's uniqueIdentifier so boot-time
// resolveThemeIndex() can restore this theme on next startup.
uiconfig.screen_rgb_color = COLOR565(255, 255, (getVisibleThemeByIndex(visibleIdx).uniqueIdentifier & 0x1F) << 3);
loadThemeDefaults();
saveUIConfig();
screen->runNow();
}
}
};
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::handleMenuSwitch(OLEDDisplay *display)
{
if (menuQueue != MenuNone)
@@ -2724,9 +2665,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MuiPicker:
switchToMUIMenu();
break;
case TftColorMenuPicker:
TFTColorPickerMenu(display);
break;
case BrightnessPicker:
BrightnessPickerMenu();
break;
@@ -2799,6 +2737,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MessageBubblesMenu:
messageBubblesMenu();
break;
case ThemeMenu:
themeMenu();
break;
}
menuQueue = MenuNone;
}
@@ -2810,4 +2751,4 @@ void menuHandler::saveUIConfig()
} // namespace graphics
#endif
#endif
+3 -16
View File
@@ -30,7 +30,6 @@ class menuHandler
ResetNodeDbMenu,
BuzzerModeMenuPicker,
MuiPicker,
TftColorMenuPicker,
BrightnessPicker,
RebootMenu,
ShutdownMenu,
@@ -55,7 +54,8 @@ class menuHandler
NodeNameLengthMenu,
FrameToggles,
DisplayUnits,
MessageBubblesMenu
MessageBubblesMenu,
ThemeMenu
};
static screenMenus menuQueue;
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
@@ -89,7 +89,6 @@ class menuHandler
static void GPSPositionBroadcastMenu();
static void BuzzerModeMenu();
static void switchToMUIMenu();
static void TFTColorPickerMenu(OLEDDisplay *display);
static void nodeListMenu();
static void resetNodeDBMenu();
static void BrightnessPickerMenu();
@@ -110,6 +109,7 @@ class menuHandler
static void frameTogglesMenu();
static void displayUnitsMenu();
static void messageBubblesMenu();
static void themeMenu();
static void textMessageMenu();
private:
@@ -136,23 +136,10 @@ template <typename T> struct MenuOption {
MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {}
};
struct ScreenColor {
uint8_t r;
uint8_t g;
uint8_t b;
bool useVariant;
explicit ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false)
: r(rIn), g(gIn), b(bIn), useVariant(variantIn)
{
}
};
using RadioPresetOption = MenuOption<meshtastic_Config_LoRaConfig_ModemPreset>;
using LoraRegionOption = MenuOption<meshtastic_Config_LoRaConfig_RegionCode>;
using TimezoneOption = MenuOption<const char *>;
using CompassOption = MenuOption<meshtastic_CompassMode>;
using ScreenColorOption = MenuOption<ScreenColor>;
using GPSToggleOption = MenuOption<meshtastic_Config_PositionConfig_GpsMode>;
using GPSFormatOption = MenuOption<meshtastic_DeviceUIConfig_GpsCoordinateFormat>;
using NodeNameOption = MenuOption<bool>;
+132 -12
View File
@@ -11,6 +11,8 @@
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/TimeFormatters.h"
#include "graphics/emotes.h"
#include "main.h"
@@ -254,6 +256,76 @@ struct MessageBlock {
bool mine;
};
#if GRAPHICS_TFT_COLORING_ENABLED
static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine)
{
uint16_t bubbleOnColor;
uint16_t bubbleOffColor;
if (themeId == ThemeID::Blue) {
bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White;
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue;
} else {
bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg();
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray;
}
setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor);
}
static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius)
{
if (w <= 0 || h <= 0) {
return;
}
if (radius <= 0 || w < 3 || h < 3) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
return;
}
// Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while
// scrolling long message lists (which can flatten older bubble corners).
int capRows = 0;
if (radius >= 4 && h >= 5) {
capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps)
} else if (radius >= 2 && h >= 3) {
capRows = 1; // 3 regions total
}
if (capRows <= 0) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
return;
}
for (int row = 0; row < capRows; ++row) {
int inset = 0;
if (radius >= 4) {
inset = (row == 0) ? 2 : 1;
} else if (radius >= 2) {
inset = 1;
}
const int stripW = w - (inset * 2);
if (stripW <= 0) {
continue;
}
const int topY = y + row;
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1);
const int bottomY = y + h - 1 - row;
if (bottomY != topY) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1);
}
}
const int middleY = y + capRows;
const int middleH = h - (capRows * 2);
if (middleH > 0) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH);
}
}
#endif
static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
{
if (isHeaderLine) {
@@ -648,6 +720,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
const int contentBottom = scrollBottom; // already excludes nav line
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
#if GRAPHICS_TFT_COLORING_ENABLED
const uint32_t themeId = getActiveTheme().id;
// Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark.
const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue);
#endif
std::vector<int> lineTop;
lineTop.resize(cachedLines.size());
@@ -686,6 +763,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
int bottomY = visualBottom + BUBBLE_PAD_Y;
// On high-res screens, keep a 1px gap under the header
if (currentResolution == ScreenResolution::High) {
const int minTopY = contentTop + 1;
if (topY < minTopY) {
// Preserve bubble height when we push it down from the header.
const int shift = minTopY - topY;
topY = minTopY;
bottomY += shift;
}
}
if (bi + 1 < blocks.size()) {
int nextHeaderIndex = (int)blocks[bi + 1].start;
int nextTop = lineTop[nextHeaderIndex];
@@ -735,24 +823,56 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
const int by = topY;
const int bw = bubbleW;
const int bh = bubbleH;
#if GRAPHICS_TFT_COLORING_ENABLED
const bool drawBubbleOutline = !useDarkModeBubbleFill;
#else
const bool drawBubbleOutline = true;
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
if (useDarkModeBubbleFill) {
setDarkModeBubbleRoleColors(themeId, b.mine);
registerRoundedBubbleFillRegion(bx, by, bw, bh, r);
}
#endif
// Draw the 4 corner arcs using drawCircleQuads
display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
if (drawBubbleOutline) {
// Draw the 4 corner arcs using drawCircleQuads
display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
// Draw the 4 edges between corners
display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
// Draw the 4 edges between corners
display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
}
} else if (bubbleW > 1 && bubbleH > 1) {
// Fallback to simple rectangle for very small bubbles
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
#if GRAPHICS_TFT_COLORING_ENABLED
const bool drawBubbleOutline = !useDarkModeBubbleFill;
#else
const bool drawBubbleOutline = true;
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
if (useDarkModeBubbleFill) {
setDarkModeBubbleRoleColors(themeId, b.mine);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH);
}
#endif
if (drawBubbleOutline) {
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
}
}
}
} // end if (showBubbles)
#if GRAPHICS_TFT_COLORING_ENABLED
if (useDarkModeBubbleFill) {
// Restore theme role defaults so other screens keep their intended palette.
loadThemeDefaults();
}
#endif
// Render visible lines
int lineY = yOffset;
@@ -772,7 +892,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
headerX = x + textIndent;
}
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
false);
true);
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
+57
View File
@@ -11,6 +11,8 @@
#include "gps/RTC.h" // for getTime() function
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "meshUtils.h"
#include <algorithm>
@@ -213,6 +215,33 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
}
}
static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName,
int16_t nameX, int16_t y, int nameMaxWidth)
{
if (!display || !node || !node->is_favorite || !isTFTColoringEnabled() || !nodeName) {
return;
}
const int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
const int regionWidth = min(textWidth, max(0, nameMaxWidth));
if (regionWidth <= 0) {
return;
}
// Node list rows can begin a couple of pixels inside header space.
// Clamp favorite-name color region below the header to avoid black overlap there.
const int16_t minContentY = static_cast<int16_t>(FONT_HEIGHT_SMALL + 1);
const int16_t regionY = max(y, minContentY);
const int16_t yClip = regionY - y;
const int16_t regionHeight = static_cast<int16_t>(FONT_HEIGHT_SMALL - yClip);
if (regionHeight <= 0) {
return;
}
setAndRegisterTFTColorRole(TFTColorRole::FavoriteNode, TFTPalette::Yellow, TFTPalette::Black, nameX, regionY, regionWidth,
regionHeight);
}
// =============================
// Entry Renderers
// =============================
@@ -227,6 +256,9 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char timeStr[10];
@@ -286,6 +318,9 @@ 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);
@@ -315,6 +350,19 @@ 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);
@@ -350,6 +398,9 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char distStr[10] = "";
@@ -455,6 +506,9 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -710,6 +764,9 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
display->setColor(WHITE);
#if GRAPHICS_TFT_COLORING_ENABLED
registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
#endif
// Text
display->drawString(boxLeft + padding, boxTop + padding, buf);
+35 -1
View File
@@ -7,6 +7,8 @@
#include "UIRenderer.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "input/RotaryEncoderInterruptImpl1.h"
#include "input/UpDownInterruptImpl1.h"
@@ -608,6 +610,9 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
display->setColor(WHITE);
#if GRAPHICS_TFT_COLORING_ENABLED
registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
#endif
// Draw Content
int16_t lineY = boxTop + vPadding;
@@ -630,7 +635,21 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) {
background_yOffset = -1;
}
display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
const int16_t titleBarY = boxTop + 1;
const int16_t titleBarHeight = effectiveLineHeight - background_yOffset;
display->fillRect(boxLeft, titleBarY, boxWidth, titleBarHeight);
#if GRAPHICS_TFT_COLORING_ENABLED
if (alertBannerOptions > 0) {
const uint16_t titleTextColor =
(getActiveTheme().id == ThemeID::DefaultLight) ? TFTPalette::Black : getThemeHeaderText();
// Keep title role away from border/corner pixels so rounded-corner masks are not remapped to the title text
// color.
if (boxWidth > 2 && titleBarHeight > 0) {
setAndRegisterTFTColorRole(TFTColorRole::ActionMenuTitle, getThemeHeaderBg(), titleTextColor, boxLeft + 1,
titleBarY, boxWidth - 2, titleBarHeight);
}
}
#endif
display->setColor(BLACK);
int yOffset = 3;
if (current_notification_type == notificationTypeEnum::node_picker) {
@@ -650,6 +669,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
const int barSpacing = 2;
const int barHeightStep = 2;
const int gap = 6;
const int maxBarHeight = totalBars * barHeightStep;
int textWidth = display->getStringWidth(lineBuffer, strlen(lineBuffer), true);
int barsWidth = totalBars * barWidth + (totalBars - 1) * barSpacing + gap;
@@ -664,6 +684,20 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
int baseX = groupStartX + textWidth + gap;
int baseY = lineY + effectiveLineHeight - 1;
#if GRAPHICS_TFT_COLORING_ENABLED
if (graphics::bannerSignalBars > 0) {
uint16_t signalBarsColor = TFTPalette::Medium;
if (graphics::bannerSignalBars <= 1) {
signalBarsColor = TFTPalette::Bad;
} else if (graphics::bannerSignalBars >= 4) {
signalBarsColor = TFTPalette::Good;
}
const int activeBars = min(graphics::bannerSignalBars, totalBars);
const int regionWidth = activeBars * barWidth + (activeBars - 1) * barSpacing;
setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, baseX,
baseY - maxBarHeight, regionWidth, maxBarHeight);
}
#endif
for (int b = 0; b < totalBars; b++) {
int barHeight = (b + 1) * barHeightStep;
int x = baseX + b * (barWidth + barSpacing);
File diff suppressed because it is too large Load Diff
+2
View File
@@ -54,6 +54,8 @@ class UIRenderer
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
// Icon and screen drawing functions
static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+142 -9
View File
@@ -25,6 +25,116 @@
using namespace NicheGraphics::Drivers;
#if defined(T5_S3_EPAPER_PRO_V2)
// FastEPD helper symbols are defined in FastEPD.inl with C++ linkage.
extern void bbepPCA9535DigitalWrite(uint8_t pin, uint8_t value);
extern uint8_t bbepPCA9535DigitalRead(uint8_t pin);
extern int bbepI2CWrite(unsigned char iAddr, unsigned char *pData, int iLen);
extern int bbepI2CReadRegister(unsigned char iAddr, unsigned char u8Register, unsigned char *pData, int iLen);
#endif
namespace
{
#if defined(T5_S3_EPAPER_PRO_V2)
// FastEPD default V2 power callback blocks forever waiting for PWRGOOD.
// Replace it with a timeout-safe version so boot never deadlocks.
int safeEPDiyV7EinkPower(void *pBBEP, int bOn)
{
static bool warnedPgood = false;
static bool warnedTpsPg = false;
static bool warnedTpsWrite = false;
FASTEPDSTATE *pState = static_cast<FASTEPDSTATE *>(pBBEP);
if (!pState) {
return BBEP_ERROR_BAD_PARAMETER;
}
if (bOn == pState->pwr_on) {
return BBEP_SUCCESS;
}
if (bOn) {
bbepPCA9535DigitalWrite(8, 1); // OE on
bbepPCA9535DigitalWrite(9, 1); // GMOD on
bbepPCA9535DigitalWrite(13, 1); // WAKEUP on
bbepPCA9535DigitalWrite(11, 1); // PWRUP on
bbepPCA9535DigitalWrite(12, 1); // VCOM CTRL on
delay(1);
const uint32_t pgoodStart = millis();
bool pgoodSeen = false;
while (!bbepPCA9535DigitalRead(14)) { // CFG_PIN_PWRGOOD
if ((millis() - pgoodStart) > 1200) {
if (!warnedPgood) {
LOG_WARN("ED047TC1: PWRGOOD timeout, continuing with fallback power-on path");
warnedPgood = true;
}
break;
}
delay(1);
}
if (bbepPCA9535DigitalRead(14)) {
pgoodSeen = true;
}
uint8_t ucTemp[4] = {0};
ucTemp[0] = 0x01; // TPS_REG_ENABLE
ucTemp[1] = 0x3f; // enable rails
const int tpsEnableRc = bbepI2CWrite(0x68, ucTemp, 2);
const int vcom = pState->iVCOM / -10;
ucTemp[0] = 3; // VCOM registers 3+4 (L + H)
ucTemp[1] = static_cast<uint8_t>(vcom);
ucTemp[2] = static_cast<uint8_t>(vcom >> 8);
const int tpsVcomRc = bbepI2CWrite(0x68, ucTemp, 3);
if ((tpsEnableRc == 0 || tpsVcomRc == 0) && !warnedTpsWrite) {
LOG_WARN("ED047TC1: TPS write did not ACK, continuing with fallback");
warnedTpsWrite = true;
}
int iTimeout = 0;
uint8_t u8Value = 0;
while (iTimeout < 220 && ((u8Value & 0xfa) != 0xfa)) {
bbepI2CReadRegister(0x68, 0x0F, &u8Value, 1); // TPS_REG_PG
iTimeout++;
delay(1);
}
if (iTimeout >= 220 && !warnedTpsPg) {
if (pgoodSeen) {
LOG_WARN("ED047TC1: TPS power-good register timeout, panel may still work");
} else {
LOG_WARN("ED047TC1: TPS power-good register timeout after PWRGOOD fallback");
}
warnedTpsPg = true;
}
pState->pwr_on = 1;
} else {
bbepPCA9535DigitalWrite(8, 0); // OE off
bbepPCA9535DigitalWrite(9, 0); // GMOD off
bbepPCA9535DigitalWrite(11, 0); // PWRUP off
bbepPCA9535DigitalWrite(12, 0); // VCOM CTRL off
delay(1);
bbepPCA9535DigitalWrite(13, 0); // WAKEUP off
pState->pwr_on = 0;
}
return BBEP_SUCCESS;
}
#endif
class SafeFastEPD : public FASTEPD
{
public:
void installSafePowerHandler()
{
#if defined(T5_S3_EPAPER_PRO_V2)
_state.pfnEinkPower = safeEPDiyV7EinkPower;
#endif
}
};
} // namespace
void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
{
// Parallel display — SPI parameters are not used
@@ -34,24 +144,48 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_
(void)pin_busy;
(void)pin_rst;
epaper = new FASTEPD;
SafeFastEPD *safeEpaper = new SafeFastEPD;
epaper = safeEpaper;
int initRc = BBEP_ERROR_BAD_PARAMETER;
#if defined(T5_S3_EPAPER_PRO_V1)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
// Initialize all PCA9535 port-0 pins as outputs / HIGH
for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT);
epaper->ioWrite(i, HIGH);
}
// On this board, the physical side key is labeled IO48; electrically it maps to PCA9535 IO12 (bit 2 on port-1).
// FastEPD's generic V7 init drives 8..13 as outputs; force IO12 back to input
// so variant touch-control polling can read the key reliably.
epaper->ioPinMode(10, INPUT);
#else
#error "ED047TC1 driver: unsupported variant — define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2"
#endif
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true); // Blocking initial clear
if (initRc != BBEP_SUCCESS) {
LOG_ERROR("ED047TC1 initPanel failed rc=%d", initRc);
return;
}
safeEpaper->installSafePowerHandler();
const int modeRc = epaper->setMode(BB_MODE_1BPP);
if (modeRc != BBEP_SUCCESS) {
LOG_WARN("ED047TC1 setMode failed rc=%d", modeRc);
}
const int clearRc = epaper->clearWhite();
if (clearRc != BBEP_SUCCESS) {
LOG_WARN("ED047TC1 clearWhite failed rc=%d", clearRc);
}
const int fullRc = epaper->fullUpdate(true); // Blocking initial clear
if (fullRc != BBEP_SUCCESS) {
LOG_WARN("ED047TC1 initial fullUpdate failed rc=%d", fullRc);
}
}
void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
@@ -111,9 +245,8 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
epaper->fullUpdate(CLEAR_SLOW, false);
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
} else {
// FAST: true partial update — compares pCurrent vs pPrevious and only applies
// the update waveform to rows that actually changed. Unchanged rows get a neutral
// signal (no visible effect). partialUpdate() updates pPrevious internally.
// FAST: true partial update - compares pCurrent vs pPrevious and only applies
// update waveform to rows that changed. partialUpdate() updates pPrevious.
epaper->partialUpdate(false, 0, dstTotalRows - 1);
}
}
+9
View File
@@ -104,6 +104,15 @@ class Applet : public GFX
virtual void onFreeText(char c) {}
virtual void onFreeTextDone() {}
virtual void onFreeTextCancel() {}
// Absolute display-space touch point, for touch-friendly UI interactions.
// Return true if consumed.
virtual bool onTouchPoint(uint16_t x, uint16_t y, bool longPress)
{
(void)x;
(void)y;
(void)longPress;
return false;
}
// List of inputs which can be subscribed to
enum InputMask { // | No Joystick | With Joystick |
BUTTON_SHORT = 1, // | Button Click | Joystick Center Click |
@@ -43,8 +43,8 @@ void InkHUD::MapApplet::onRender(bool full)
// Add white halo outline first
constexpr int outlinePad = 1;
int boxSize = 11;
int radius = 2; // rounded corner radius
int boxSize = fontSmall.lineHeight() + 2; // scale with font so digit fits
int radius = max(2, boxSize / 6);
// White halo background
fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE);
@@ -143,17 +143,19 @@ void InkHUD::MapApplet::onRender(bool full)
int16_t centerX = X(0.5) + (self.eastMeters * metersToPx);
int16_t centerY = Y(0.5) - (self.northMeters * metersToPx);
int16_t r = fontSmall.lineHeight() / 2; // scale marker with font
// White fill background + halo
fillCircle(centerX, centerY, 8, WHITE); // big white base
drawCircle(centerX, centerY, 8, WHITE); // crisp edge
fillCircle(centerX, centerY, r + 2, WHITE);
drawCircle(centerX, centerY, r + 2, WHITE);
// Black bullseye on top
drawCircle(centerX, centerY, 6, BLACK);
fillCircle(centerX, centerY, 2, BLACK);
drawCircle(centerX, centerY, r, BLACK);
fillCircle(centerX, centerY, max(2, r / 4), BLACK);
// Crosshairs
drawLine(centerX - 8, centerY, centerX + 8, centerY, BLACK);
drawLine(centerX, centerY - 8, centerX, centerY + 8, BLACK);
drawLine(centerX - r - 2, centerY, centerX + r + 2, centerY, BLACK);
drawLine(centerX, centerY - r - 2, centerX, centerY + r + 2, BLACK);
}
}
@@ -382,9 +384,9 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
constexpr uint16_t paddingH = 2;
constexpr uint16_t paddingW = 4;
uint16_t paddingInnerW = 2; // Zero'd out if no text
constexpr uint16_t markerSizeMax = 12; // Size of cross (if marker uses a cross)
constexpr uint16_t markerSizeMin = 5;
uint16_t paddingInnerW = 2; // Zero'd out if no text
uint16_t markerSizeMax = fontSmall.lineHeight(); // Scale cross with font
uint16_t markerSizeMin = max(5, fontSmall.lineHeight() / 3);
int16_t textX;
int16_t textY;
@@ -0,0 +1,545 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./AppSwitcherApplet.h"
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/Tile.h"
#include <algorithm>
#include <cctype>
using namespace NicheGraphics;
namespace
{
static constexpr uint16_t BODY_MARGIN_X = 8;
static constexpr uint16_t BODY_MARGIN_Y = 6;
static constexpr uint16_t SLOT_GAP_X = 8;
static constexpr uint16_t SLOT_GAP_Y = 8;
static constexpr uint8_t ICON_RADIUS = 8;
static constexpr uint16_t FOOTER_PAD = 4;
static constexpr uint16_t LABEL_BOTTOM_PAD = 1;
static constexpr uint16_t LABEL_GAP_Y = 1;
static constexpr uint16_t TITLE_H_PAD = 8;
static constexpr uint8_t GRID_COLS = 3;
static constexpr uint8_t GRID_ROWS = 4;
static constexpr uint8_t ICON_NATIVE_SIZE = 48;
static constexpr uint8_t ICON_OUTLINE_STROKE = 1;
enum class IconKind : uint8_t { GENERIC, ALL_MESSAGES, DMS, CHANNEL, POSITIONS, RECENTS, HEARD, FAVORITES };
struct GridLayout {
uint16_t footerH = 0;
uint16_t bodyTop = 0;
uint16_t bodyBottom = 0;
uint16_t slotW = 0;
uint16_t slotH = 0;
uint16_t iconBox = 0;
};
/*
* Icons sourced from Material Design Icons PNG set (Apache 2.0):
* https://github.com/material-icons/material-icons-png
*
* Families used: outline-2x (48x48)
* apps, markunread, chat, forum, place, history, hearing, star_border
*/
static constexpr uint64_t icon_generic_apps[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
static constexpr uint64_t icon_all_messages[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL,
0x0FC0000003F0ULL, 0x0FE0000007F0ULL, 0x0FF800001FF0ULL, 0x0FFE00007FF0ULL, 0x0FFF0000FFF0ULL, 0x0F7FC003FEF0ULL,
0x0F1FE007F8F0ULL, 0x0F07F81FE0F0ULL, 0x0F03FE7FC0F0ULL, 0x0F00FFFF00F0ULL, 0x0F007FFE00F0ULL, 0x0F001FF800F0ULL,
0x0F0007E000F0ULL, 0x0F0003C000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x07FFFFFFFFE0ULL, 0x000000000000ULL, 0x000000000000ULL,
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
static constexpr uint64_t icon_dms[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL,
0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F7FFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFE0ULL,
0x0FF000000000ULL, 0x0FE000000000ULL, 0x0FC000000000ULL, 0x0F8000000000ULL, 0x0F0000000000ULL, 0x0E0000000000ULL,
0x0C0000000000ULL, 0x080000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
static constexpr uint64_t icon_channel[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL,
0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL,
0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL,
0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F7FFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL,
0x0FFFFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL, 0x0FF0000003F0ULL, 0x0FE0000003F0ULL, 0x0FC0000003F0ULL, 0x0F80000003F0ULL,
0x0F0FFFFFFFF0ULL, 0x0E0FFFFFFFF0ULL, 0x0C0FFFFFFFF0ULL, 0x080FFFFFFFF0ULL, 0x000FFFFFFFF0ULL, 0x000FFFFFFFF0ULL,
0x000000000FF0ULL, 0x0000000007F0ULL, 0x0000000003F0ULL, 0x0000000001F0ULL, 0x0000000000F0ULL, 0x000000000070ULL,
0x000000000030ULL, 0x000000000010ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
static constexpr uint64_t icon_positions[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x00001FF80000ULL, 0x00007FFE0000ULL,
0x0001FFFF8000ULL, 0x0003FFFFC000ULL, 0x0007FFFFE000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x001F8001F800ULL,
0x001F0000F800ULL, 0x003F07E0FC00ULL, 0x003E0FF07C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL,
0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E0FF07C00ULL, 0x003E07E07C00ULL, 0x001F0000F800ULL,
0x001F0000F800ULL, 0x001F8001F800ULL, 0x000F8001F000ULL, 0x000FC003F000ULL, 0x0007C003E000ULL, 0x0007E007E000ULL,
0x0003F00FC000ULL, 0x0003F00FC000ULL, 0x0001F81F8000ULL, 0x0001FC3F8000ULL, 0x0000FC3F0000ULL, 0x00007E7E0000ULL,
0x00003FFC0000ULL, 0x00003FFC0000ULL, 0x00001FF80000ULL, 0x00000FF00000ULL, 0x00000FF00000ULL, 0x000007E00000ULL,
0x000003C00000ULL, 0x000001800000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
static constexpr uint64_t icon_recents[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
0x00000FFF0000ULL, 0x00003FFFC000ULL, 0x0000FFFFF000ULL, 0x0001FFFFF800ULL, 0x0007FFFFFE00ULL, 0x000FF801FF00ULL,
0x000FE0007F00ULL, 0x001FC0003F80ULL, 0x003F00000FC0ULL, 0x003F00000FC0ULL, 0x007E00E007E0ULL, 0x007C00E003E0ULL,
0x00FC00E003F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL,
0x3FFFC0F001F0ULL, 0x1FFF80FC01F0ULL, 0x0FFF00FF01F0ULL, 0x07FE003F81F0ULL, 0x03FC001FC1F0ULL, 0x01F80007C3F0ULL,
0x00F0000183E0ULL, 0x0060000007E0ULL, 0x000000000FC0ULL, 0x000000000FC0ULL, 0x0001C0003F80ULL, 0x0003E0007F00ULL,
0x0007F801FF00ULL, 0x0007FFFFFE00ULL, 0x0001FFFFF800ULL, 0x0000FFFFF000ULL, 0x00003FFFC000ULL, 0x00000FFF0000ULL,
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
static constexpr uint64_t icon_heard[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000800000000ULL, 0x001C00000000ULL, 0x003E01FF8000ULL, 0x007F07FFE000ULL,
0x007E1FFFF800ULL, 0x00FC3FFFFC00ULL, 0x00F87FFFFE00ULL, 0x01F8FF00FF00ULL, 0x01F0FC003F00ULL, 0x01F1F8001F80ULL,
0x03E1F0000F80ULL, 0x03E3F07E0FC0ULL, 0x03E3E0FF07C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL,
0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E0FF0000ULL, 0x03E3F07E0000ULL, 0x03E1F0000000ULL,
0x01F1F8000000ULL, 0x01F1F8000000ULL, 0x01F8FC000000ULL, 0x00F87E000000ULL, 0x00FC7F800000ULL, 0x007E3FC00000ULL,
0x007F1FE00000ULL, 0x003E0FF00000ULL, 0x001C03F00000ULL, 0x000801F80000ULL, 0x000000F80000ULL, 0x000000FC0000ULL,
0x0000007C07C0ULL, 0x0000007E07C0ULL, 0x0000003F0FC0ULL, 0x0000003FFFC0ULL, 0x0000001FFF80ULL, 0x0000000FFF00ULL,
0x00000007FE00ULL, 0x00000003FC00ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
static constexpr uint64_t icon_favorites[48] = {
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000001800000ULL, 0x000001800000ULL,
0x000003C00000ULL, 0x000003C00000ULL, 0x000003C00000ULL, 0x000007E00000ULL, 0x000007E00000ULL, 0x00000FF00000ULL,
0x00000FF00000ULL, 0x00001FF80000ULL, 0x00001FF80000ULL, 0x00001E780000ULL, 0x00003E7C0000ULL, 0x003FFC3FFC00ULL,
0x0FFFFC3FFFF0ULL, 0x0FFFF81FFFF0ULL, 0x03FFF81FFFC0ULL, 0x01F800001F80ULL, 0x00FC00003F00ULL, 0x007E00007E00ULL,
0x003F8001FC00ULL, 0x001FC003F800ULL, 0x000FE007F000ULL, 0x0003E007C000ULL, 0x0003E007C000ULL, 0x0003C003C000ULL,
0x0003C003C000ULL, 0x0003C3C3C000ULL, 0x0007CFF3E000ULL, 0x00079FF9E000ULL, 0x0007FFFFE000ULL, 0x0007FE7FE000ULL,
0x000FFC3FF000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x000F8001F000ULL, 0x001E00007000ULL, 0x001800001800ULL,
0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
};
using IconBitmap = const uint64_t *;
IconBitmap iconBitmapForKind(IconKind kind)
{
switch (kind) {
case IconKind::ALL_MESSAGES:
return icon_all_messages;
case IconKind::DMS:
return icon_dms;
case IconKind::CHANNEL:
return icon_channel;
case IconKind::POSITIONS:
return icon_positions;
case IconKind::RECENTS:
return icon_recents;
case IconKind::HEARD:
return icon_heard;
case IconKind::FAVORITES:
return icon_favorites;
case IconKind::GENERIC:
default:
return icon_generic_apps;
}
}
GridLayout computeLayout(const InkHUD::Applet *applet)
{
GridLayout layout;
const uint16_t w = applet->width();
const uint16_t h = applet->height();
layout.footerH = InkHUD::Applet::fontSmall.lineHeight() + (FOOTER_PAD * 2);
layout.bodyTop = BODY_MARGIN_Y;
layout.bodyBottom = (h > (layout.footerH + BODY_MARGIN_Y)) ? (h - layout.footerH - BODY_MARGIN_Y) : layout.bodyTop;
const uint16_t bodyW = (w > (BODY_MARGIN_X * 2)) ? (w - (BODY_MARGIN_X * 2)) : 1;
const uint16_t bodyH = (layout.bodyBottom > layout.bodyTop) ? (layout.bodyBottom - layout.bodyTop) : 1;
const uint16_t gapsX = SLOT_GAP_X * (GRID_COLS - 1);
const uint16_t gapsY = SLOT_GAP_Y * (GRID_ROWS - 1);
layout.slotW = (bodyW > gapsX) ? ((bodyW - gapsX) / GRID_COLS) : 1;
layout.slotH = (bodyH > gapsY) ? ((bodyH - gapsY) / GRID_ROWS) : 1;
const uint16_t maxIconW = (layout.slotW > 6) ? (layout.slotW - 6) : layout.slotW;
const uint16_t maxIconH = (layout.slotH > (InkHUD::Applet::fontSmall.lineHeight() + LABEL_GAP_Y + LABEL_BOTTOM_PAD + 6))
? (layout.slotH - InkHUD::Applet::fontSmall.lineHeight() - LABEL_GAP_Y - LABEL_BOTTOM_PAD - 6)
: layout.slotH / 2;
layout.iconBox = std::max<uint16_t>(20, std::min<uint16_t>(maxIconW, maxIconH));
return layout;
}
std::string lowercase(const char *name)
{
if (!name)
return "";
std::string out(name);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return (char)std::tolower(c); });
return out;
}
IconKind iconKindForAppletName(const char *name)
{
const std::string lower = lowercase(name);
if (lower.find("all message") != std::string::npos || lower.find("messages") != std::string::npos)
return IconKind::ALL_MESSAGES;
if (lower.find("dm") != std::string::npos)
return IconKind::DMS;
if (lower.find("channel") != std::string::npos)
return IconKind::CHANNEL;
if (lower.find("position") != std::string::npos)
return IconKind::POSITIONS;
if (lower.find("recent") != std::string::npos)
return IconKind::RECENTS;
if (lower.find("heard") != std::string::npos)
return IconKind::HEARD;
if (lower.find("favorite") != std::string::npos)
return IconKind::FAVORITES;
return IconKind::GENERIC;
}
void drawIconBitmapScaled(InkHUD::Applet *applet, IconBitmap bmp48, int16_t left, int16_t top, uint16_t boxSize, uint16_t color)
{
if (!bmp48 || boxSize == 0)
return;
auto srcOn = [bmp48](int16_t sx, int16_t sy) -> bool {
if (sx < 0 || sy < 0 || sx >= ICON_NATIVE_SIZE || sy >= ICON_NATIVE_SIZE)
return false;
const uint64_t rowBits = bmp48[sy];
return (rowBits & (1ULL << (47 - sx))) != 0;
};
for (uint16_t y = 0; y < boxSize; y++) {
const uint8_t srcY = (uint8_t)((y * ICON_NATIVE_SIZE) / boxSize);
for (uint16_t x = 0; x < boxSize; x++) {
const uint8_t srcX = (uint8_t)((x * ICON_NATIVE_SIZE) / boxSize);
if (!srcOn(srcX, srcY))
continue;
const uint16_t w = std::min<uint16_t>(ICON_OUTLINE_STROKE, boxSize - x);
const uint16_t h = std::min<uint16_t>(ICON_OUTLINE_STROKE, boxSize - y);
applet->fillRect(left + x, top + y, w, h, color);
}
}
}
} // namespace
InkHUD::AppSwitcherApplet::AppSwitcherApplet()
{
alwaysRender = true;
}
void InkHUD::AppSwitcherApplet::rebuildActiveAppletList()
{
activeAppletIndices.clear();
const auto &settings = inkhud->persistence->settings;
const uint8_t tileCount = std::min<uint8_t>(settings.userTiles.count, Persistence::MAX_TILES_GLOBAL);
const uint8_t focusedTile = (tileCount > 0) ? std::min<uint8_t>(settings.userTiles.focused, tileCount - 1) : 0;
// Applets displayed on other tiles should not be selectable here.
std::vector<bool> occupiedOnOtherTiles(inkhud->userApplets.size(), false);
for (uint8_t tile = 0; tile < tileCount; tile++) {
if (tile == focusedTile)
continue;
const uint8_t appletIndex = settings.userTiles.displayedUserApplet[tile];
if (appletIndex < occupiedOnOtherTiles.size())
occupiedOnOtherTiles[appletIndex] = true;
}
for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
Applet *a = inkhud->userApplets.at(i);
if (a && a->isActive() && !occupiedOnOtherTiles[i])
activeAppletIndices.push_back(i);
}
}
uint8_t InkHUD::AppSwitcherApplet::cardsPerPage() const
{
return GRID_COLS * GRID_ROWS;
}
uint8_t InkHUD::AppSwitcherApplet::currentPage() const
{
const uint8_t cpp = cardsPerPage();
if (cpp == 0)
return 0;
return selectedIndex / cpp;
}
void InkHUD::AppSwitcherApplet::stepPage(int8_t delta)
{
if (activeAppletIndices.empty())
return;
const uint8_t cpp = cardsPerPage();
const uint8_t pageCount = std::max<uint8_t>(1, (activeAppletIndices.size() + cpp - 1) / cpp);
int16_t nextPage = (int16_t)currentPage() + delta;
while (nextPage < 0)
nextPage += pageCount;
while (nextPage >= pageCount)
nextPage -= pageCount;
selectedIndex = std::min<uint8_t>((uint8_t)(nextPage * cpp), activeAppletIndices.size() - 1);
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::AppSwitcherApplet::clampSelection()
{
if (activeAppletIndices.empty()) {
selectedIndex = 0;
return;
}
if (selectedIndex >= activeAppletIndices.size())
selectedIndex = activeAppletIndices.size() - 1;
}
void InkHUD::AppSwitcherApplet::activateSelectedApplet()
{
if (activeAppletIndices.empty()) {
sendToBackground();
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
return;
}
const uint8_t appletIndex = activeAppletIndices.at(selectedIndex);
sendToBackground();
inkhud->showApplet(appletIndex);
}
void InkHUD::AppSwitcherApplet::onForeground()
{
rebuildActiveAppletList();
clampSelection();
handleInput = true;
lockRequests = true;
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::AppSwitcherApplet::onBackground()
{
handleInput = false;
lockRequests = false;
if (borrowedTileOwner)
borrowedTileOwner->bringToForeground();
Tile *t = getTile();
if (t)
t->assignApplet(borrowedTileOwner);
borrowedTileOwner = nullptr;
}
void InkHUD::AppSwitcherApplet::show(Tile *t)
{
if (!t)
return;
borrowedTileOwner = t->getAssignedApplet();
if (borrowedTileOwner)
borrowedTileOwner->sendToBackground();
t->assignApplet(this);
bringToForeground();
}
void InkHUD::AppSwitcherApplet::onRender(bool full)
{
(void)full;
const GridLayout layout = computeLayout(this);
const uint8_t cpp = cardsPerPage();
const uint8_t page = currentPage();
const uint8_t pageStart = page * cpp;
setFont(fontMedium);
setTextColor(BLACK);
fillRect(0, 0, width(), height(), WHITE);
drawRect(0, 0, width(), height(), BLACK);
if (activeAppletIndices.empty()) {
setFont(fontSmall);
printAt(width() / 2, height() / 2, "No Available Applets", CENTER, MIDDLE);
return;
}
for (uint8_t i = 0; i < cpp; i++) {
const uint8_t idx = pageStart + i;
if (idx >= activeAppletIndices.size())
break;
const uint8_t row = i / GRID_COLS;
const uint8_t col = i % GRID_COLS;
const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X));
const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y));
const bool selected = (idx == selectedIndex);
const uint8_t appletIndex = activeAppletIndices.at(idx);
Applet *a = inkhud->userApplets.at(appletIndex);
if (!a)
continue;
const int16_t iconLeft = slotL + ((layout.slotW - layout.iconBox) / 2);
const int16_t iconTop = slotT + 1;
// Requested style: icon in outlined rounded square only (no filled box, no outer app card).
drawRoundRect(iconLeft, iconTop, layout.iconBox, layout.iconBox, ICON_RADIUS, BLACK);
if (selected)
drawRoundRect(iconLeft + 2, iconTop + 2, layout.iconBox - 4, layout.iconBox - 4, ICON_RADIUS, BLACK);
const IconBitmap bmp = iconBitmapForKind(iconKindForAppletName(a->name));
drawIconBitmapScaled(this, bmp, iconLeft + 3, iconTop + 3, layout.iconBox - 6, BLACK);
setFont(fontSmall);
std::string label = a->name ? a->name : "Applet";
const uint16_t maxLabelW = layout.slotW > 4 ? (layout.slotW - 4) : layout.slotW;
if (getTextWidth(label) > maxLabelW) {
while (!label.empty() && getTextWidth(label + "...") > maxLabelW)
label.pop_back();
label = label.empty() ? "..." : label + "...";
}
const int16_t labelY = iconTop + layout.iconBox + LABEL_GAP_Y;
setTextColor(BLACK);
printAt(slotL + (layout.slotW / 2), labelY, label.c_str(), CENTER, TOP);
if (a->isForeground())
fillCircle(iconLeft + layout.iconBox - 4, iconTop + 4, 2, BLACK);
}
const uint8_t pageCount = std::max<uint8_t>(1, (activeAppletIndices.size() + cpp - 1) / cpp);
if (pageCount > 1) {
setFont(fontSmall);
setTextColor(BLACK);
const int16_t footerY = height() - layout.footerH + FOOTER_PAD;
printAt(TITLE_H_PAD, footerY, "<", LEFT, TOP);
printAt(width() - TITLE_H_PAD, footerY, ">", RIGHT, TOP);
const std::string pageText = std::to_string(page + 1) + "/" + std::to_string(pageCount);
printAt(width() / 2, footerY, pageText.c_str(), CENTER, TOP);
}
}
bool InkHUD::AppSwitcherApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
{
(void)longPress;
Tile *t = getTile();
if (!t || activeAppletIndices.empty())
return true;
const uint16_t tileL = t->getLeft();
const uint16_t tileT = t->getTop();
const uint16_t tileR = tileL + t->getWidth();
const uint16_t tileB = tileT + t->getHeight();
if (x < tileL || x >= tileR || y < tileT || y >= tileB)
return false;
const GridLayout layout = computeLayout(this);
const uint8_t cpp = cardsPerPage();
const uint8_t page = currentPage();
const uint8_t pageStart = page * cpp;
const int16_t localX = (int16_t)x - (int16_t)tileL;
const int16_t localY = (int16_t)y - (int16_t)tileT;
for (uint8_t i = 0; i < cpp; i++) {
const uint8_t idx = pageStart + i;
if (idx >= activeAppletIndices.size())
break;
const uint8_t row = i / GRID_COLS;
const uint8_t col = i % GRID_COLS;
const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X));
const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y));
if (localX < slotL || localX >= (slotL + (int16_t)layout.slotW))
continue;
if (localY < slotT || localY >= (slotT + (int16_t)layout.slotH))
continue;
selectedIndex = idx;
clampSelection();
activateSelectedApplet();
return true;
}
const uint8_t pageCount = std::max<uint8_t>(1, (activeAppletIndices.size() + cpp - 1) / cpp);
if (pageCount <= 1)
return true;
const int16_t footerTop = height() - layout.footerH;
if (localY >= footerTop) {
if (localX < (int16_t)(width() / 3))
stepPage(-1);
else if (localX >= (int16_t)((width() * 2) / 3))
stepPage(1);
}
return true;
}
void InkHUD::AppSwitcherApplet::onButtonShortPress()
{
if (activeAppletIndices.empty())
return;
selectedIndex = (selectedIndex + 1) % activeAppletIndices.size();
clampSelection();
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::AppSwitcherApplet::onButtonLongPress()
{
activateSelectedApplet();
}
void InkHUD::AppSwitcherApplet::onExitShort()
{
sendToBackground();
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::AppSwitcherApplet::onNavUp()
{
if (activeAppletIndices.empty())
return;
if (selectedIndex == 0)
selectedIndex = activeAppletIndices.size() - 1;
else
selectedIndex--;
clampSelection();
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::AppSwitcherApplet::onNavDown()
{
if (activeAppletIndices.empty())
return;
selectedIndex = (selectedIndex + 1) % activeAppletIndices.size();
clampSelection();
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
#endif
@@ -0,0 +1,51 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#pragma once
#include "configuration.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#include <vector>
namespace NicheGraphics::InkHUD
{
class Tile;
class AppSwitcherApplet : public SystemApplet
{
public:
AppSwitcherApplet();
void onForeground() override;
void onBackground() override;
void onRender(bool full) override;
void onButtonShortPress() override;
void onButtonLongPress() override;
void onExitShort() override;
void onNavUp() override;
void onNavDown() override;
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
// Open the app switcher on a user tile and temporarily replace the tile's owner.
void show(Tile *t);
private:
void rebuildActiveAppletList();
void clampSelection();
uint8_t cardsPerPage() const;
uint8_t currentPage() const;
void stepPage(int8_t delta);
void activateSelectedApplet();
std::vector<uint8_t> activeAppletIndices;
uint8_t selectedIndex = 0;
Applet *borrowedTileOwner = nullptr;
};
} // namespace NicheGraphics::InkHUD
#endif
@@ -1,155 +1,100 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./KeyboardApplet.h"
#include <cctype>
using namespace NicheGraphics;
namespace
{
bool usePortraitKeyboardSizing()
{
InkHUD::InkHUD *inkhud = InkHUD::InkHUD::getInstance();
return inkhud && inkhud->height() > inkhud->width();
}
} // namespace
InkHUD::KeyboardApplet::KeyboardApplet()
{
// Calculate row widths
for (uint8_t row = 0; row < KBD_ROWS; row++) {
rowWidths[row] = 0;
for (uint8_t col = 0; col < KBD_COLS; col++)
rowWidths[row] += keyWidths[row * KBD_COLS + col];
}
mode = MODE_TEXT;
lastTypingMode = MODE_TEXT;
emotePage = 0;
selectedKey = 0;
prevSelectedKey = 0;
normalizeSelection();
}
void InkHUD::KeyboardApplet::onRender(bool full)
{
uint16_t em = fontSmall.lineHeight(); // 16 pt
uint16_t keyH = Y(1.0) / KBD_ROWS;
int16_t keyTopPadding = (keyH - fontSmall.lineHeight()) / 2;
const bool showSelection = showSelectionHighlight();
if (full) { // Draw full keyboard
for (uint8_t row = 0; row < KBD_ROWS; row++) {
// Calculate the remaining space to be used as padding
int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
// Draw keys
uint16_t xPos = 0;
for (uint8_t col = 0; col < KBD_COLS; col++) {
Color fgcolor = BLACK;
uint8_t index = row * KBD_COLS + col;
uint16_t keyX = ((xPos * em) >> 4) + ((col * keyXPadding) / (KBD_COLS - 1));
uint16_t keyY = row * keyH;
uint16_t keyW = (keyWidths[index] * em) >> 4;
if (index == selectedKey) {
fgcolor = WHITE;
fillRect(keyX, keyY, keyW, keyH, BLACK);
}
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[index], fgcolor);
xPos += keyWidths[index];
}
}
} else { // Only draw the difference
if (selectedKey != prevSelectedKey) {
// Draw previously selected key
uint8_t row = prevSelectedKey / KBD_COLS;
int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
uint16_t xPos = 0;
for (uint8_t i = prevSelectedKey - (prevSelectedKey % KBD_COLS); i < prevSelectedKey; i++)
xPos += keyWidths[i];
uint16_t keyX = ((xPos * em) >> 4) + (((prevSelectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
uint16_t keyY = row * keyH;
uint16_t keyW = (keyWidths[prevSelectedKey] * em) >> 4;
fillRect(keyX, keyY, keyW, keyH, WHITE);
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[prevSelectedKey], BLACK);
// Draw newly selected key
row = selectedKey / KBD_COLS;
keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
xPos = 0;
for (uint8_t i = selectedKey - (selectedKey % KBD_COLS); i < selectedKey; i++)
xPos += keyWidths[i];
keyX = ((xPos * em) >> 4) + (((selectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
keyY = row * keyH;
keyW = (keyWidths[selectedKey] * em) >> 4;
fillRect(keyX, keyY, keyW, keyH, BLACK);
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[selectedKey], WHITE);
}
if (full) {
for (uint8_t i = 0; i < KBD_KEY_COUNT; i++)
drawKey(i, showSelection && i == selectedKey);
} else if (showSelection && selectedKey != prevSelectedKey) {
drawKey(prevSelectedKey, false);
drawKey(selectedKey, true);
}
prevSelectedKey = selectedKey;
}
// Draw the key label corresponding to the char
// for most keys it draws the character itself
// for ['\b', '\n', ' ', '\x1b'] it draws special glyphs
void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color)
void InkHUD::KeyboardApplet::drawKey(uint8_t index, bool selected)
{
if (key == '\b') {
// Draw backspace glyph: 13 x 9 px
/**
* [][][][][][][][][]
* [][] []
* [][] [] [] []
* [][] [] [] []
* [][] [] []
* [][] [] [] []
* [][] [] [] []
* [][] []
* [][][][][][][][][]
*/
const uint8_t bsBitmap[] = {0x0f, 0xf8, 0x18, 0x08, 0x32, 0x28, 0x61, 0x48, 0xc0,
0x88, 0x61, 0x48, 0x32, 0x28, 0x18, 0x08, 0x0f, 0xf8};
uint16_t leftPadding = (width - 13) >> 1;
drawBitmap(left + leftPadding, top + 1, bsBitmap, 13, 9, color);
} else if (key == '\n') {
// Draw done glyph: 12 x 9 px
/**
* [][]
* [][]
* [][]
* [][]
* [][]
* [][] [][]
* [][] [][]
* [][][]
* []
*/
const uint8_t doneBitmap[] = {0x00, 0x30, 0x00, 0x60, 0x00, 0xc0, 0x01, 0x80, 0x03,
0x00, 0xc6, 0x00, 0x6c, 0x00, 0x38, 0x00, 0x10, 0x00};
uint16_t leftPadding = (width - 12) >> 1;
drawBitmap(left + leftPadding, top + 1, doneBitmap, 12, 9, color);
} else if (key == ' ') {
// Draw space glyph: 13 x 9 px
/**
*
*
*
*
* [] []
* [] []
* [][][][][][][][][][][][][]
*
*
*/
const uint8_t spaceBitmap[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
0x08, 0x80, 0x08, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00};
uint16_t leftPadding = (width - 13) >> 1;
drawBitmap(left + leftPadding, top + 1, spaceBitmap, 13, 9, color);
} else if (key == '\x1b') {
setTextColor(color);
std::string keyText = "ESC";
uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
printAt(left + leftPadding, top, keyText);
} else {
setTextColor(color);
if (key >= 0x61)
key -= 32; // capitalize
std::string keyText = std::string(1, key);
uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
printAt(left + leftPadding, top, keyText);
uint16_t keyX = 0;
uint16_t keyY = 0;
uint16_t keyW = 0;
uint16_t keyH = 0;
if (!getKeyBounds(index, keyX, keyY, keyW, keyH))
return;
if (keyW == 0 || keyH == 0)
return;
// Translate absolute tile coordinates into applet-local coordinates.
const int16_t localX = keyX - getTile()->getLeft();
const int16_t localY = keyY - getTile()->getTop();
const bool enabled = isKeyEnabledAt(index);
// Clean background first so hidden keys never leave stale pixels when mode changes.
fillRect(localX, localY, keyW, keyH, WHITE);
if (!enabled)
return;
fillRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, selected ? BLACK : WHITE);
drawRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, BLACK);
const int16_t labelTop = localY + ((keyH - fontSmall.lineHeight()) / 2);
drawKeyLabel(localX, labelTop, keyW, getKeyLabelAt(index), selected ? WHITE : BLACK);
}
void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color)
{
if (label.empty())
return;
setTextColor(color);
uint16_t textW = getTextWidth(label);
if (textW > width) {
// Keep labels readable in narrow keys.
textW = getTextWidth("..");
printAt(left + ((width - textW) >> 1), top, "..");
return;
}
uint16_t leftPadding = (width - textW) >> 1;
printAt(left + leftPadding, top, label);
}
void InkHUD::KeyboardApplet::onForeground()
{
handleInput = true; // Intercept the button input for our applet
// Select the first key
handleInput = true;
mode = MODE_TEXT;
lastTypingMode = MODE_TEXT;
emotePage = 0;
selectedKey = 0;
prevSelectedKey = 0;
normalizeSelection();
}
void InkHUD::KeyboardApplet::onBackground()
@@ -159,32 +104,12 @@ void InkHUD::KeyboardApplet::onBackground()
void InkHUD::KeyboardApplet::onButtonShortPress()
{
char key = keys[selectedKey];
if (key == '\n') {
inkhud->freeTextDone();
inkhud->closeKeyboard();
} else if (key == '\x1b') {
inkhud->freeTextCancel();
inkhud->closeKeyboard();
} else {
inkhud->freeText(key);
}
inputSelectedKey(false);
}
void InkHUD::KeyboardApplet::onButtonLongPress()
{
char key = keys[selectedKey];
if (key == '\n') {
inkhud->freeTextDone();
inkhud->closeKeyboard();
} else if (key == '\x1b') {
inkhud->freeTextCancel();
inkhud->closeKeyboard();
} else {
if (key >= 0x61)
key -= 32; // capitalize
inkhud->freeText(key);
}
inputSelectedKey(true);
}
void InkHUD::KeyboardApplet::onExitShort()
@@ -201,57 +126,377 @@ void InkHUD::KeyboardApplet::onExitLong()
void InkHUD::KeyboardApplet::onNavUp()
{
if (selectedKey < KBD_COLS) // wrap
if (selectedKey < KBD_COLS)
selectedKey += KBD_COLS * (KBD_ROWS - 1);
else // move 1 row back
else
selectedKey -= KBD_COLS;
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
normalizeSelection();
requestFastKeyboardRefresh();
}
void InkHUD::KeyboardApplet::onNavDown()
{
selectedKey += KBD_COLS;
selectedKey %= (KBD_COLS * KBD_ROWS);
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
selectedKey %= KBD_KEY_COUNT;
normalizeSelection();
requestFastKeyboardRefresh();
}
void InkHUD::KeyboardApplet::onNavLeft()
{
if (selectedKey % KBD_COLS == 0) // wrap
if (selectedKey % KBD_COLS == 0)
selectedKey += KBD_COLS - 1;
else // move 1 column back
else
selectedKey--;
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
normalizeSelection();
requestFastKeyboardRefresh();
}
void InkHUD::KeyboardApplet::onNavRight()
{
if (selectedKey % KBD_COLS == KBD_COLS - 1) // wrap
if (selectedKey % KBD_COLS == KBD_COLS - 1)
selectedKey -= KBD_COLS - 1;
else // move 1 column forward
else
selectedKey++;
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
normalizeSelection();
requestFastKeyboardRefresh();
}
bool InkHUD::KeyboardApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
{
// If touch is outside our tile, let other handlers process it.
if (!getTile())
return false;
const uint16_t tileL = getTile()->getLeft();
const uint16_t tileT = getTile()->getTop();
const uint16_t tileR = tileL + getTile()->getWidth();
const uint16_t tileB = tileT + getTile()->getHeight();
if (x < tileL || x >= tileR || y < tileT || y >= tileB)
return false;
const int16_t hitIndex = getKeyIndexAt(x, y);
// Consume touches that land in keyboard whitespace/disabled cells so we don't
// fall back to generic short-press behavior (which would type the old selection).
if (hitIndex < 0)
return true;
const uint8_t newSelected = (uint8_t)hitIndex;
if (selectedKey != newSelected) {
selectedKey = newSelected;
normalizeSelection();
if (showSelectionHighlight())
requestFastKeyboardRefresh();
}
if (!isKeyEnabledAt(selectedKey))
return true;
inputSelectedKey(longPress);
return true;
}
bool InkHUD::KeyboardApplet::getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height)
{
if (index >= KBD_KEY_COUNT || !getTile())
return false;
const uint16_t tileW = getTile()->getWidth();
const uint16_t tileH = getTile()->getHeight();
const uint16_t tileL = getTile()->getLeft();
const uint16_t tileT = getTile()->getTop();
const uint8_t row = index / KBD_COLS;
const uint8_t col = index % KBD_COLS;
const uint16_t totalGapY = KEY_GAP_Y * (KBD_ROWS + 1);
const uint16_t keyH = (tileH > totalGapY) ? ((tileH - totalGapY) / KBD_ROWS) : (tileH / KBD_ROWS);
top = tileT + KEY_GAP_Y + row * (keyH + KEY_GAP_Y);
height = keyH;
const uint16_t totalGapX = KEY_GAP_X * (KBD_COLS + 1);
const uint16_t rowSpace = (tileW > totalGapX) ? (tileW - totalGapX) : tileW;
uint32_t rowUnits = 0;
const uint8_t rowStart = row * KBD_COLS;
for (uint8_t i = 0; i < KBD_COLS; i++) {
rowUnits += getKeyWidthAt(rowStart + i);
}
if (rowUnits == 0)
return false;
uint32_t cursorX = tileL + KEY_GAP_X;
for (uint8_t i = 0; i < col; i++) {
const uint8_t rowIndex = rowStart + i;
const uint32_t keyW = ((uint32_t)rowSpace * getKeyWidthAt(rowIndex)) / rowUnits;
cursorX += keyW + KEY_GAP_X;
}
left = (uint16_t)cursorX;
if (col == (KBD_COLS - 1)) {
const uint32_t rightEdge = tileL + tileW - KEY_GAP_X;
width = (rightEdge > cursorX) ? (uint16_t)(rightEdge - cursorX) : 0;
} else {
width = (uint16_t)(((uint32_t)rowSpace * getKeyWidthAt(index)) / rowUnits);
}
return true;
}
int16_t InkHUD::KeyboardApplet::getKeyIndexAt(uint16_t x, uint16_t y)
{
for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) {
uint16_t keyL = 0;
uint16_t keyT = 0;
uint16_t keyW = 0;
uint16_t keyH = 0;
if (!getKeyBounds(i, keyL, keyT, keyW, keyH))
return -1;
if (keyW == 0 || keyH == 0)
continue;
if (x >= keyL && x < (keyL + keyW) && y >= keyT && y < (keyT + keyH))
return i;
}
return -1;
}
void InkHUD::KeyboardApplet::inputSelectedKey(bool longPress)
{
inputKeyCode(getKeyCodeAt(selectedKey), longPress);
}
void InkHUD::KeyboardApplet::inputKeyCode(int16_t keyCode, bool longPress)
{
if (keyCode == KEY_NONE)
return;
if (keyCode >= KEY_EMOTE_SLOT_BASE) {
const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE);
const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot;
if (emoteIndex < fontEmoteCount)
inkhud->freeText((char)fontEmotes[emoteIndex]);
return;
}
switch (keyCode) {
case KEY_BACKSPACE:
inkhud->freeText('\b');
return;
case KEY_SEND:
inkhud->freeTextDone();
inkhud->closeKeyboard();
return;
case KEY_EMOTE_TOGGLE:
toggleEmoteMode();
return;
case KEY_PUNCT_TOGGLE:
case KEY_ALPHA_TOGGLE:
togglePunctuationMode();
return;
case KEY_EMOTE_UP:
pageEmotes(false);
return;
case KEY_EMOTE_DOWN:
pageEmotes(true);
return;
default:
break;
}
if (keyCode >= 0 && keyCode <= 0xFF) {
char key = (char)keyCode;
if (longPress && key >= 'a' && key <= 'z')
key = (char)std::toupper((unsigned char)key);
inkhud->freeText(key);
}
}
int16_t InkHUD::KeyboardApplet::getKeyCodeAt(uint8_t index) const
{
if (index >= KBD_KEY_COUNT)
return KEY_NONE;
if (mode == MODE_TEXT)
return textKeys[index];
if (mode == MODE_PUNCT)
return punctKeys[index];
// Emote mode
if (index < EMOTE_SLOT_COUNT) {
const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + index;
if (emoteIndex < fontEmoteCount)
return KEY_EMOTE_SLOT_BASE + index;
return KEY_NONE;
}
// Emote controls on the bottom row
switch (index - EMOTE_SLOT_COUNT) {
case 0:
return KEY_EMOTE_UP;
case 1:
return KEY_EMOTE_DOWN;
case 2:
return KEY_ALPHA_TOGGLE;
case 3:
return ',';
case 4:
return ' ';
case 5:
return '.';
case 6:
return KEY_SEND;
case 7:
return KEY_BACKSPACE;
default:
return KEY_NONE;
}
}
uint16_t InkHUD::KeyboardApplet::getKeyWidthAt(uint8_t index) const
{
if (index >= KBD_KEY_COUNT)
return 0;
if (mode == MODE_EMOTE)
return emoteKeyWidths[index];
return typingKeyWidths[index];
}
std::string InkHUD::KeyboardApplet::getKeyLabelAt(uint8_t index) const
{
const int16_t keyCode = getKeyCodeAt(index);
if (keyCode == KEY_NONE)
return "";
if (keyCode >= KEY_EMOTE_SLOT_BASE) {
const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE);
const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot;
if (emoteIndex < fontEmoteCount)
return std::string(1, (char)fontEmotes[emoteIndex]);
return "";
}
switch (keyCode) {
case KEY_BACKSPACE:
return "DEL";
case KEY_SEND:
return "SEND";
case KEY_EMOTE_TOGGLE:
return std::string(1, (char)0x03); // Smiling face icon from InkHUD emote font map
case KEY_PUNCT_TOGGLE:
return "!#1";
case KEY_ALPHA_TOGGLE:
return "ABC";
case KEY_EMOTE_UP:
return "UP";
case KEY_EMOTE_DOWN:
return "DN";
default:
break;
}
if (keyCode >= 0 && keyCode <= 0xFF) {
const char c = (char)keyCode;
if (c == ' ')
return "SPACE";
if (c >= 'a' && c <= 'z')
return std::string(1, (char)std::toupper((unsigned char)c));
return std::string(1, c);
}
return "";
}
bool InkHUD::KeyboardApplet::isKeyEnabledAt(uint8_t index) const
{
return getKeyCodeAt(index) != KEY_NONE;
}
void InkHUD::KeyboardApplet::normalizeSelection()
{
if (selectedKey >= KBD_KEY_COUNT)
selectedKey = 0;
if (isKeyEnabledAt(selectedKey))
return;
for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) {
if (isKeyEnabledAt(i)) {
selectedKey = i;
return;
}
}
}
void InkHUD::KeyboardApplet::togglePunctuationMode()
{
if (mode == MODE_EMOTE) {
mode = lastTypingMode;
} else {
mode = (mode == MODE_TEXT) ? MODE_PUNCT : MODE_TEXT;
lastTypingMode = mode;
}
normalizeSelection();
requestFastKeyboardRefresh(true);
}
void InkHUD::KeyboardApplet::toggleEmoteMode()
{
if (mode == MODE_EMOTE) {
mode = lastTypingMode;
} else {
lastTypingMode = mode;
mode = MODE_EMOTE;
}
emotePage = 0;
normalizeSelection();
requestFastKeyboardRefresh(true);
}
void InkHUD::KeyboardApplet::pageEmotes(bool down)
{
if (mode != MODE_EMOTE)
return;
const uint8_t maxPage = (fontEmoteCount == 0) ? 0 : (uint8_t)((fontEmoteCount - 1) / EMOTE_SLOT_COUNT);
if (down) {
if (emotePage < maxPage)
emotePage++;
} else {
if (emotePage > 0)
emotePage--;
}
normalizeSelection();
requestFastKeyboardRefresh(true);
}
void InkHUD::KeyboardApplet::requestFastKeyboardRefresh(bool full)
{
requestUpdate(EInk::UpdateTypes::FAST, full);
}
bool InkHUD::KeyboardApplet::showSelectionHighlight() const
{
// On touch-capable devices, prioritize input throughput over per-key highlight updates.
// E-ink refresh can lag rapid taps; skipping highlight avoids update-induced input latency.
return !inkhud->hasTouchEnabledProvider();
}
uint16_t InkHUD::KeyboardApplet::getKeyboardHeight()
{
const uint16_t keyH = fontSmall.lineHeight() * 1.2;
return keyH * KBD_ROWS;
// Keep touch keys tall and roomy for finger input.
// In portrait orientation we increase row height for larger touch targets.
const uint16_t rowUnit = fontSmall.lineHeight() + 8;
const uint8_t rowScale = usePortraitKeyboardSizing() ? 3 : 2;
const uint16_t keyH = rowUnit * rowScale;
return (keyH * KBD_ROWS) + (KEY_GAP_Y * (KBD_ROWS + 1));
}
#endif
@@ -12,6 +12,7 @@ System Applet to render an on-screen keyboard
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#include <string>
namespace NicheGraphics::InkHUD
{
@@ -31,34 +32,111 @@ class KeyboardApplet : public SystemApplet
void onNavDown() override;
void onNavLeft() override;
void onNavRight() override;
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
static uint16_t getKeyboardHeight(); // used to set the keyboard tile height
private:
void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color);
enum KeyCode : int16_t {
KEY_NONE = -1,
KEY_BACKSPACE = 256,
KEY_SEND,
KEY_EMOTE_TOGGLE,
KEY_PUNCT_TOGGLE,
KEY_ALPHA_TOGGLE,
KEY_EMOTE_UP,
KEY_EMOTE_DOWN,
KEY_EMOTE_SLOT_BASE = 512
};
enum KeyboardMode : uint8_t { MODE_TEXT = 0, MODE_PUNCT = 1, MODE_EMOTE = 2 };
void drawKey(uint8_t index, bool selected);
void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color);
bool getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height);
int16_t getKeyIndexAt(uint16_t x, uint16_t y);
void inputSelectedKey(bool longPress);
void inputKeyCode(int16_t keyCode, bool longPress);
int16_t getKeyCodeAt(uint8_t index) const;
uint16_t getKeyWidthAt(uint8_t index) const;
std::string getKeyLabelAt(uint8_t index) const;
bool isKeyEnabledAt(uint8_t index) const;
void normalizeSelection();
void togglePunctuationMode();
void toggleEmoteMode();
void pageEmotes(bool down);
void requestFastKeyboardRefresh(bool full = false);
bool showSelectionHighlight() const;
static const uint8_t KBD_COLS = 11;
static const uint8_t KBD_ROWS = 4;
static const uint8_t KBD_ROWS = 5;
static const uint8_t KBD_KEY_COUNT = KBD_COLS * KBD_ROWS;
static const uint8_t EMOTE_SLOT_COUNT = KBD_COLS * (KBD_ROWS - 1); // top 4 rows
static constexpr uint8_t fontEmotes[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0B, 0x0C, 0x0E, 0x0F, 0x10, 0x11,
0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F};
static constexpr uint8_t fontEmoteCount = sizeof(fontEmotes) / sizeof(fontEmotes[0]);
const char keys[KBD_COLS * KBD_ROWS] = {
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '\b', // row 0
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '\n', // row 1
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '!', ' ', // row 2
'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '?', '\x1b' // row 3
};
// Text keyboard (requested layout):
// row 0: 1..0
// row 1: q..p
// row 2: a..l
// row 3: EMO, z..m, DEL
// row 4: !#1, comma, space, period, SEND
const int16_t textKeys[KBD_KEY_COUNT] = {
// row 0
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE,
// row 1
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', KEY_NONE,
// row 2
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', KEY_NONE, KEY_NONE,
// row 3
KEY_EMOTE_TOGGLE, 'z', 'x', 'c', 'v', 'b', 'n', 'm', KEY_BACKSPACE, KEY_NONE, KEY_NONE,
// row 4
KEY_PUNCT_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE};
// This array represents the widths of each key in points
// 16 pt = line height of the text
const uint16_t keyWidths[KBD_COLS * KBD_ROWS] = {
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 0
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 1
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 2
16, 16, 16, 16, 16, 16, 16, 10, 10, 12, 40 // row 3
};
// Punctuation keyboard (toggle via !#1/ABC)
const int16_t punctKeys[KBD_KEY_COUNT] = {
// row 0
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE,
// row 1
'!', '@', '#', '$', '%', '^', '&', '*', '(', ')', KEY_NONE,
// row 2
'-', '_', '=', '+', '[', ']', '{', '}', '/', '?', KEY_NONE,
// row 3
KEY_EMOTE_TOGGLE, ';', ':', '\'', '"', '<', '>', '\\', KEY_BACKSPACE, KEY_NONE, KEY_NONE,
// row 4
KEY_ALPHA_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE};
uint16_t rowWidths[KBD_ROWS];
uint8_t selectedKey = 0; // selected key index
const uint16_t typingKeyWidths[KBD_KEY_COUNT] = {// row 0
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0,
// row 1
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0,
// row 2
12, 12, 12, 12, 12, 12, 12, 12, 12, 0, 0,
// row 3
18, 12, 12, 12, 12, 12, 12, 12, 20, 0, 0,
// row 4
20, 12, 56, 12, 24, 0, 0, 0, 0, 0, 0};
const uint16_t emoteKeyWidths[KBD_KEY_COUNT] = {// row 0
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
// row 1
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
// row 2
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
// row 3
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
// row 4 controls
14, 14, 18, 12, 40, 12, 20, 18, 0, 0, 0};
uint8_t selectedKey = 0;
uint8_t prevSelectedKey = 0;
uint8_t emotePage = 0;
KeyboardMode mode = MODE_TEXT;
KeyboardMode lastTypingMode = MODE_TEXT;
static constexpr uint8_t KEY_GAP_X = 3;
static constexpr uint8_t KEY_GAP_Y = 4;
static constexpr uint8_t KEY_RADIUS = 4;
};
} // namespace NicheGraphics::InkHUD
@@ -109,6 +109,7 @@ enum MenuAction {
TOGGLE_CHANNEL_POSITION,
SET_CHANNEL_PRECISION,
// Display
SET_DISPLAY_TIMEOUT,
TOGGLE_DISPLAY_UNITS,
// Network
TOGGLE_WIFI,
@@ -119,4 +120,4 @@ enum MenuAction {
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -8,6 +8,7 @@
#include "RTC.h"
#include "Router.h"
#include "airtime.h"
#include "graphics/niche/Utils/FlashData.h"
#include "main.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "power.h"
@@ -27,6 +28,16 @@ static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu
// These are offered to users as possible values for settings.recentlyActiveSeconds
static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120};
struct DisplayTimeoutOption {
uint32_t seconds;
const char *label;
};
static constexpr DisplayTimeoutOption DISPLAY_TIMEOUT_OPTIONS[] = {
{0, "Forever"}, {30, "30 secs"}, {60, "1 min"}, {5 * 60, "5 min"},
{15 * 60, "15 min"}, {30 * 60, "30 min"}, {60 * 60, "1 hr"},
};
struct PositionPrecisionOption {
uint8_t value; // proto value
const char *metric;
@@ -39,6 +50,77 @@ static constexpr PositionPrecisionOption POSITION_PRECISION_OPTIONS[] = {
{12, "5.8 km", "3.6 mi"}, {11, "12 km", "7.3 mi"}, {10, "23 km", "15 mi"},
};
static const char *getDisplayTimeoutLabel(uint32_t timeoutSeconds)
{
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
for (uint8_t i = 0; i < optionCount; i++) {
if (DISPLAY_TIMEOUT_OPTIONS[i].seconds == timeoutSeconds) {
return DISPLAY_TIMEOUT_OPTIONS[i].label;
}
}
return "Custom";
}
static bool supportsFreeTextKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
{
return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
}
static bool useTouchFriendlyMenuLayout(const InkHUD::InkHUD *inkhud)
{
return inkhud != nullptr && inkhud->hasTouchEnabledProvider();
}
static uint16_t getMenuItemHeightPx(const InkHUD::InkHUD *inkhud)
{
const bool touchFriendly = useTouchFriendlyMenuLayout(inkhud);
const uint16_t lineH = touchFriendly ? InkHUD::Applet::fontMedium.lineHeight() : InkHUD::Applet::fontSmall.lineHeight();
const float rowScale = touchFriendly ? 1.9f : 1.6f;
uint16_t itemH = (uint16_t)(lineH * rowScale);
if (itemH == 0) {
itemH = 1;
}
return itemH;
}
#if defined(T5_S3_EPAPER_PRO)
namespace
{
static constexpr uint32_t T5_BACKLIGHT_PREFS_VERSION = 1;
struct T5BacklightPrefs {
uint32_t version = T5_BACKLIGHT_PREFS_VERSION;
bool keepOn = true;
};
T5BacklightPrefs t5BacklightPrefs;
bool t5BacklightPrefsLoaded = false;
bool loadT5BacklightKeepOn()
{
if (!t5BacklightPrefsLoaded) {
T5BacklightPrefs loaded;
const bool ok = FlashData<T5BacklightPrefs>::load(&loaded, "t5_backlight");
if (ok && loaded.version == T5_BACKLIGHT_PREFS_VERSION) {
t5BacklightPrefs = loaded;
}
t5BacklightPrefsLoaded = true;
}
return t5BacklightPrefs.keepOn;
}
void saveT5BacklightKeepOn(bool keepOn)
{
loadT5BacklightKeepOn();
t5BacklightPrefs.version = T5_BACKLIGHT_PREFS_VERSION;
t5BacklightPrefs.keepOn = keepOn;
FlashData<T5BacklightPrefs>::save(&t5BacklightPrefs, "t5_backlight");
}
} // namespace
#endif
InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
{
// No timer tasks at boot
@@ -47,7 +129,11 @@ InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
// Note: don't get instance if we're not actually using the backlight,
// or else you will unintentionally instantiate it
if (settings->optionalMenuItems.backlight) {
#if defined(T5_S3_EPAPER_PRO)
t5BacklightSetUserEnabled(loadT5BacklightKeepOn());
#else
backlight = Drivers::LatchingBacklight::getInstance();
#endif
}
// Initialize the Canned Message store
@@ -76,9 +162,11 @@ void InkHUD::MenuApplet::onForeground()
// backlight on always when menu opens.
// Courtesy to T-Echo users who removed the capacitive touch button
if (settings->optionalMenuItems.backlight) {
#if !defined(T5_S3_EPAPER_PRO)
assert(backlight);
if (!backlight->isOn())
backlight->peek();
#endif
}
// Prevent user applets requesting update while menu is open
@@ -106,9 +194,11 @@ void InkHUD::MenuApplet::onBackground()
// Item in options submenu allows keeping backlight on after menu is closed
// If this item is deselected we will turn backlight off again, now that menu is closing
if (settings->optionalMenuItems.backlight) {
#if !defined(T5_S3_EPAPER_PRO)
assert(backlight);
if (!backlight->isLatched())
backlight->off();
#endif
}
// Stop the auto-timeout
@@ -333,17 +423,14 @@ void InkHUD::MenuApplet::execute(MenuItem item)
handleFreeText = true;
cm.freeTextItem.rawText.erase(); // clear the previous freetext message
freeTextMode = true; // render input field instead of normal menu
// Open the on-screen keyboard only for full joystick devices
if (settings->joystick.enabled && !inkhud->twoWayRocker)
if (supportsFreeTextKeyboard(inkhud, settings))
inkhud->openKeyboard();
break;
case STORE_CANNEDMESSAGE_SELECTION:
if (!settings->joystick.enabled || inkhud->twoWayRocker)
cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry
else
cm.selectedMessageItem = &cm.messageItems.at(cursor - 2); // Minus two: offset for the "Send Ping" and free text entry
break;
case STORE_CANNEDMESSAGE_SELECTION: {
const uint8_t prefixItems = supportsFreeTextKeyboard(inkhud, settings) ? 2 : 1;
cm.selectedMessageItem = &cm.messageItems.at(cursor - prefixItems);
} break;
case SEND_CANNEDMESSAGE:
cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
@@ -422,14 +509,27 @@ void InkHUD::MenuApplet::execute(MenuItem item)
break;
case TOGGLE_BACKLIGHT:
// Note: backlight is already on in this situation
// We're marking that it should *remain* on once menu closes
assert(backlight);
// Note: backlight is already on in this situation.
// This toggle controls whether it should remain on when menu closes.
#if defined(T5_S3_EPAPER_PRO)
{
const bool keepOn = !t5BacklightIsUserEnabled();
t5BacklightSetUserEnabled(keepOn);
saveT5BacklightKeepOn(keepOn);
if (item.checkState)
*(item.checkState) = keepOn;
}
#else
if (!backlight)
backlight = Drivers::LatchingBacklight::getInstance();
if (backlight->isLatched())
backlight->off();
else
backlight->latch();
break;
if (item.checkState)
*(item.checkState) = backlight->isLatched();
#endif
break;
case TOGGLE_12H_CLOCK:
config.display.use_12h_clock = !config.display.use_12h_clock;
@@ -527,6 +627,17 @@ void InkHUD::MenuApplet::execute(MenuItem item)
}
// Display
case SET_DISPLAY_TIMEOUT: {
// cursor - 1 because index 0 is "Back"
const uint8_t index = cursor - 1;
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
if (index < optionCount) {
config.display.screen_on_secs = DISPLAY_TIMEOUT_OPTIONS[index].seconds;
nodeDB->saveToDisk(SEGMENT_CONFIG);
}
break;
}
case TOGGLE_DISPLAY_UNITS:
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
@@ -893,11 +1004,16 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
previousPage = MenuPage::ROOT;
items.push_back(MenuItem("Back", previousPage));
// Optional: backlight
if (settings->optionalMenuItems.backlight)
items.push_back(MenuItem(backlight->isLatched() ? "Backlight Off" : "Keep Backlight On", // Label
MenuAction::TOGGLE_BACKLIGHT, // Action
MenuPage::EXIT // Exit once complete
));
if (settings->optionalMenuItems.backlight) {
#if defined(T5_S3_EPAPER_PRO)
keepBacklightOn = t5BacklightIsUserEnabled();
#else
if (!backlight)
backlight = Drivers::LatchingBacklight::getInstance();
keepBacklightOn = backlight->isLatched();
#endif
items.push_back(MenuItem("Keep Backlight On", MenuAction::TOGGLE_BACKLIGHT, MenuPage::OPTIONS, &keepBacklightOn));
}
// Options Toggles
items.push_back(MenuItem("Applets", MenuPage::APPLETS));
@@ -1109,6 +1225,9 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
items.push_back(MenuItem("12-Hour Clock", MenuAction::TOGGLE_12H_CLOCK, MenuPage::NODE_CONFIG_DISPLAY,
&config.display.use_12h_clock));
nodeConfigLabels.emplace_back("Screen Timeout: " + std::string(getDisplayTimeoutLabel(config.display.screen_on_secs)));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DISPLAY_TIMEOUT));
const char *unitsLabel =
(config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "Units: Imperial" : "Units: Metric";
@@ -1118,6 +1237,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
break;
}
case NODE_CONFIG_DISPLAY_TIMEOUT:
previousPage = MenuPage::NODE_CONFIG_DISPLAY;
items.push_back(MenuItem("Back", previousPage));
populateDisplayTimeoutPage();
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_BLUETOOTH: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
@@ -1386,10 +1512,14 @@ void InkHUD::MenuApplet::onRender(bool full)
if (items.size() == 0)
LOG_ERROR("Empty Menu");
const bool touchFriendlyLayout = useTouchFriendlyMenuLayout(inkhud);
AppletFont menuItemFont = touchFriendlyLayout ? fontMedium : fontSmall;
setFont(menuItemFont);
// Dimensions for the slots where we will draw menuItems
const float padding = 0.05;
const uint16_t itemH = fontSmall.lineHeight() * 1.6;
const int16_t selectInsetY = 2;
const uint16_t itemH = getMenuItemHeightPx(inkhud);
const int16_t selectInsetY = touchFriendlyLayout ? 3 : 2;
const int16_t itemW = width() - X(padding) - X(padding);
const int16_t itemL = X(padding);
const int16_t itemR = X(1 - padding);
@@ -1422,6 +1552,11 @@ void InkHUD::MenuApplet::onRender(bool full)
itemT = max(siT + siH, 0); // Offset the first menu entry, so menu starts below the system info panel
}
// drawSystemInfoPanel() changes font state (clock/info text).
// Restore the active menu font so ROOT page item text matches other menu pages,
// including touch-friendly layouts.
setFont(menuItemFont);
// Draw menu items
// ===================
@@ -1449,8 +1584,6 @@ void InkHUD::MenuApplet::onRender(bool full)
// Header (non-selectable section label)
if (item.isHeader) {
setFont(fontSmall);
// Header text (flush left)
printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE);
@@ -1459,8 +1592,17 @@ void InkHUD::MenuApplet::onRender(bool full)
drawLine(itemL + X(padding), underlineY, itemR - X(padding), underlineY, BLACK);
} else {
// Box, if currently selected
if (cursorShown && i == cursor)
drawRect(itemL, itemT + selectInsetY, itemW, itemH - (selectInsetY * 2), BLACK);
if (cursorShown && i == cursor && (!touchFriendlyLayout || !hideTouchSelectionHighlight)) {
const int16_t selTop = itemT + selectInsetY;
const int16_t selH = itemH - (selectInsetY * 2);
drawRect(itemL, selTop, itemW, selH, BLACK);
// Touch layouts need a stronger visual cue than a thin outline.
if (touchFriendlyLayout) {
const int16_t markerInset = 3;
const int16_t markerW = 4;
fillRect(itemL + markerInset, selTop + markerInset, markerW, max(1, selH - (markerInset * 2)), BLACK);
}
}
// Indented normal item text
printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE);
@@ -1468,9 +1610,9 @@ void InkHUD::MenuApplet::onRender(bool full)
// Checkbox, if relevant
if (item.checkState) {
const uint16_t cbWH = fontSmall.lineHeight(); // Checkbox: width / height
const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
const int16_t cbT = center - (cbWH / 2); // Checkbox : top
const uint16_t cbWH = menuItemFont.lineHeight(); // Checkbox: width / height
const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
const int16_t cbT = center - (cbWH / 2); // Checkbox : top
// Checkbox ticked
if (*(item.checkState)) {
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
@@ -1499,13 +1641,102 @@ void InkHUD::MenuApplet::onRender(bool full)
}
}
bool InkHUD::MenuApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
{
(void)longPress;
if (freeTextMode || !getTile()) {
return false;
}
const uint16_t tileL = getTile()->getLeft();
const uint16_t tileT = getTile()->getTop();
const uint16_t tileR = tileL + getTile()->getWidth();
const uint16_t tileB = tileT + getTile()->getHeight();
if (x < tileL || x >= tileR || y < tileT || y >= tileB) {
return false;
}
if (items.empty()) {
return true;
}
// Direct touch controls should act as activity and keep the menu open.
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
// If button-driven selection is active on touch-first layouts, clear it as soon as
// touch interaction resumes so touch behavior remains direct/tap-first.
if (useTouchFriendlyMenuLayout(inkhud)) {
cursorShown = false;
hideTouchSelectionHighlight = true;
}
const int16_t localY = (int16_t)y - (int16_t)tileT;
// Keep geometry in sync with onRender() so touch hit-testing matches what users see.
const uint16_t itemH = getMenuItemHeightPx(inkhud);
int16_t itemT = 0;
uint8_t slotCount = (height() - itemT) / itemH;
if (slotCount == 0) {
slotCount = 1;
}
const uint16_t &siH = systemInfoPanelHeight;
const uint8_t slotsObscured = ceilf(siH / (float)itemH);
if (currentPage == ROOT) {
int16_t siT = 0;
const int16_t scrollThreshold = (int16_t)slotCount - (int16_t)slotsObscured - 1;
if (scrollThreshold >= 0 && (int16_t)cursor >= scrollThreshold) {
siT = 0 - ((cursor - scrollThreshold) * itemH);
}
itemT = max((int16_t)(siT + siH), (int16_t)0);
}
const uint8_t firstItem = (cursor < slotCount) ? 0 : (cursor - (slotCount - 1));
uint16_t visibleEnd = (uint16_t)firstItem + (uint16_t)slotCount;
const uint8_t maxIndex = (uint8_t)items.size() - 1;
if (visibleEnd > maxIndex) {
visibleEnd = maxIndex;
}
const uint8_t lastItem = (uint8_t)visibleEnd;
for (uint8_t i = firstItem; i <= lastItem; i++) {
const int16_t rowTop = itemT;
const int16_t rowBottom = itemT + itemH;
if (localY >= rowTop && localY < rowBottom) {
if (items.at(i).isHeader) {
// Consume taps on headers so they don't fall back to button semantics.
return true;
}
cursor = i;
cursorShown = true;
execute(items.at(cursor));
if (!wantsToRender()) {
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
return true;
}
itemT += itemH;
}
// Consume taps on menu whitespace so we don't trigger button-like fallback behavior.
return true;
}
void InkHUD::MenuApplet::onButtonShortPress()
{
if (!freeTextMode) {
// Push the auto-close timer back
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
if (!settings->joystick.enabled) {
// Touch-first nodes keep user-button short-press as "advance selection" in menus.
// Any button-driven navigation should restore visible highlight.
hideTouchSelectionHighlight = false;
if (!settings->joystick.enabled || useTouchFriendlyMenuLayout(inkhud)) {
if (!cursorShown) {
cursorShown = true;
// Select the first item that isn't a header
@@ -1566,6 +1797,32 @@ void InkHUD::MenuApplet::onNavUp()
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
// Touch-first menus: swipe up/down should scroll only.
// Keep cursor movement for scroll math, but selection box is hidden in onRender().
if (useTouchFriendlyMenuLayout(inkhud)) {
hideTouchSelectionHighlight = true;
if (!cursorShown) {
cursorShown = true;
cursor = items.size() - 1;
while (items.at(cursor).isHeader) {
if (cursor == 0) {
cursorShown = false;
break;
}
cursor--;
}
} else {
do {
if (cursor == 0)
cursor = items.size() - 1;
else
cursor--;
} while (items.at(cursor).isHeader);
}
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
return;
}
if (!cursorShown) {
cursorShown = true;
// Select the last item that isn't a header
@@ -1595,6 +1852,29 @@ void InkHUD::MenuApplet::onNavDown()
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
// Touch-first menus: swipe up/down should scroll only.
// Keep cursor movement for scroll math, but selection box is hidden in onRender().
if (useTouchFriendlyMenuLayout(inkhud)) {
hideTouchSelectionHighlight = true;
if (!cursorShown) {
cursorShown = true;
cursor = 0;
while (cursor < items.size() && items.at(cursor).isHeader) {
cursor++;
}
if (cursor >= items.size()) {
cursorShown = false;
cursor = 0;
}
} else {
do {
cursor = (cursor + 1) % items.size();
} while (items.at(cursor).isHeader);
}
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
return;
}
if (!cursorShown) {
cursorShown = true;
// Select the first item that isn't a header
@@ -1727,6 +2007,17 @@ void InkHUD::MenuApplet::populateRecentsPage()
}
}
void InkHUD::MenuApplet::populateDisplayTimeoutPage()
{
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
for (uint8_t i = 0; i < optionCount; i++) {
displayTimeoutSelected[i] = (config.display.screen_on_secs == DISPLAY_TIMEOUT_OPTIONS[i].seconds);
nodeConfigLabels.emplace_back(DISPLAY_TIMEOUT_OPTIONS[i].label);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_DISPLAY_TIMEOUT, MenuPage::NODE_CONFIG_DISPLAY,
&displayTimeoutSelected[i]));
}
}
// MenuItem entries for the "send" page
// Dynamically creates menu items based on available canned messages
void InkHUD::MenuApplet::populateSendPage()
@@ -1734,8 +2025,8 @@ void InkHUD::MenuApplet::populateSendPage()
// Position / NodeInfo packet
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
// If joystick is available, include the Free Text option
if (settings->joystick.enabled && !inkhud->twoWayRocker)
// Show the Free Text option on any node that supports the on-screen keyboard.
if (supportsFreeTextKeyboard(inkhud, settings))
items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND));
// One menu item for each canned message
@@ -35,6 +35,7 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void onFreeText(char c) override;
void onFreeTextDone() override;
void onFreeTextCancel() override;
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
void onRender(bool full) override;
void show(Tile *t); // Open the menu, onto a user tile
@@ -48,11 +49,12 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void execute(MenuItem item); // Perform the MenuAction associated with a MenuItem, if any
void showPage(MenuPage page); // Load and display a MenuPage
void populateSendPage(); // Dynamically create MenuItems including canned messages
void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
void populateSendPage(); // Dynamically create MenuItems including canned messages
void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
void populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs
void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height,
const std::string &text); // Draw input field for free text
@@ -65,8 +67,9 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
MenuPage startPageOverride = MenuPage::ROOT;
MenuPage currentPage = MenuPage::ROOT;
MenuPage previousPage = MenuPage::EXIT;
uint8_t cursor = 0; // Which menu item is currently highlighted
bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
uint8_t cursor = 0; // Which menu item is currently highlighted
bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
bool hideTouchSelectionHighlight = false; // Touch scrolling keeps cursor for paging math, but can hide highlight
bool freeTextMode = false;
uint16_t systemInfoPanelHeight = 0; // Need to know before we render
uint16_t menuTextLimit = 200;
@@ -80,6 +83,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
// Recents menu checkbox state (derived from settings.recentlyActiveSeconds)
static constexpr uint8_t RECENTS_COUNT = 6;
bool recentsSelected[RECENTS_COUNT] = {};
static constexpr uint8_t DISPLAY_TIMEOUT_COUNT = 7;
bool displayTimeoutSelected[DISPLAY_TIMEOUT_COUNT] = {};
// Data for selecting and sending canned messages via the menu
// Placed into a sub-class for organization only
@@ -116,7 +121,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
Applet *borrowedTileOwner = nullptr; // Which applet we have temporarily replaced while displaying menu
bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options
bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options
bool keepBacklightOn = false; // Helper to display current backlight latch state in InkHUD options
};
} // namespace NicheGraphics::InkHUD
@@ -32,6 +32,7 @@ enum MenuPage : uint8_t {
NODE_CONFIG_POWER_ADC_CAL,
NODE_CONFIG_NETWORK,
NODE_CONFIG_DISPLAY,
NODE_CONFIG_DISPLAY_TIMEOUT,
NODE_CONFIG_BLUETOOTH,
NODE_CONFIG_POSITION,
NODE_CONFIG_ADMIN_RESET,
@@ -45,4 +46,4 @@ enum MenuPage : uint8_t {
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -0,0 +1,30 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./TouchStatusApplet.h"
using namespace NicheGraphics;
InkHUD::TouchStatusApplet::TouchStatusApplet()
{
alwaysRender = true;
}
void InkHUD::TouchStatusApplet::onRender(bool full)
{
(void)full;
if (inkhud->isTouchEnabled()) {
return;
}
setFont(fontSmall);
const uint16_t barH = fontSmall.lineHeight() + 4;
const int16_t top = height() - barH;
fillRect(0, top, width(), barH, WHITE);
drawLine(0, top, width() - 1, top, BLACK);
printAt(width() / 2, top + (barH / 2), "TOUCH OFF", CENTER, MIDDLE);
}
#endif
@@ -0,0 +1,29 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
/*
Low-profile bottom-edge touch status indicator.
Shown only while touch input is disabled.
*/
#pragma once
#include "configuration.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
namespace NicheGraphics::InkHUD
{
class TouchStatusApplet : public SystemApplet
{
public:
TouchStatusApplet();
void onRender(bool full) override;
};
} // namespace NicheGraphics::InkHUD
#endif
@@ -47,6 +47,9 @@ InkHUD::TipsApplet::TipsApplet()
void InkHUD::TipsApplet::onRender(bool full)
{
const char *continuePrompt =
(inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue";
switch (tipQueue.front()) {
case Tip::WELCOME:
renderWelcome();
@@ -79,7 +82,7 @@ void InkHUD::TipsApplet::onRender(bool full)
cursorY += fontSmall.lineHeight() / 2;
drawBullet("More info at meshtastic.org");
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::PICK_REGION: {
@@ -109,7 +112,7 @@ void InkHUD::TipsApplet::onRender(bool full)
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::CUSTOMIZATION: {
@@ -129,10 +132,13 @@ void InkHUD::TipsApplet::onRender(bool full)
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::BUTTONS: {
#if defined(T5_S3_EPAPER_PRO)
renderT5S3ButtonsTip();
#else
setFont(fontMedium);
const char *title = "Tip: Buttons";
@@ -164,7 +170,8 @@ void InkHUD::TipsApplet::onRender(bool full)
drawBullet("- press: switch tile or close menu");
}
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
#endif
} break;
case Tip::ROTATION: {
@@ -189,7 +196,7 @@ void InkHUD::TipsApplet::onRender(bool full)
"To rotate the display, use the InkHUD menu. Press the user button > Options > Rotate.");
}
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
// Revert the "flip screen" setting, preventing this message showing again
config.display.flip_screen = false;
@@ -198,6 +205,42 @@ void InkHUD::TipsApplet::onRender(bool full)
}
}
#if defined(T5_S3_EPAPER_PRO)
void InkHUD::TipsApplet::renderT5S3ButtonsTip()
{
setFont(fontMedium);
const char *title = "Tip: T5-S3 Buttons";
uint16_t h = getWrappedTextHeight(0, width(), title);
printWrapped(0, 0, width(), title);
setFont(fontSmall);
int16_t cursorY = h + fontSmall.lineHeight();
auto drawBullet = [&](const char *text) {
uint16_t bh = getWrappedTextHeight(0, width(), text);
printWrapped(0, cursorY, width(), text);
cursorY += bh + (fontSmall.lineHeight() / 3);
};
drawBullet("BOOT button");
drawBullet("- short press: next");
drawBullet("- long press: open menu or select");
drawBullet("IO48 button");
drawBullet("- short press: toggle touch on/off");
drawBullet("- long press: toggle backlight on/off");
drawBullet("PWR button");
drawBullet("- Hold Press to wake after Shutdown");
drawBullet("HOME button");
drawBullet("- press: back/exit in InkHUD and open App switcher");
printAt(0, Y(1.0), "Tap screen to continue", LEFT, BOTTOM);
}
#endif
// This tip has its own render method, only because it's a big block of code
// Didn't want to clutter up the switch in onRender too much
void InkHUD::TipsApplet::renderWelcome()
@@ -244,7 +287,9 @@ void InkHUD::TipsApplet::renderWelcome()
// Block 3 - press to continue
// ============================
printAt(X(0.5), Y(1), "Press button to continue", CENTER, BOTTOM);
const char *continuePrompt =
(inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue";
printAt(X(0.5), Y(1), continuePrompt, CENTER, BOTTOM);
}
void InkHUD::TipsApplet::onForeground()
@@ -41,6 +41,9 @@ class TipsApplet : public SystemApplet
protected:
void renderWelcome(); // Very first screen of tutorial
#if defined(T5_S3_EPAPER_PRO)
void renderT5S3ButtonsTip();
#endif
std::deque<Tip> tipQueue; // List of tips to show, one after another
@@ -49,4 +52,4 @@ class TipsApplet : public SystemApplet
} // namespace NicheGraphics::InkHUD
#endif
#endif
+272 -148
View File
@@ -2,6 +2,7 @@
#include "./Events.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "buzz.h"
#include "modules/ExternalNotificationModule.h"
@@ -14,6 +15,19 @@
using namespace NicheGraphics;
namespace
{
// When touch long-press opens menu, some panels report a delayed release/tap
// if the finger stays down briefly. Keep this long enough to cover that release.
constexpr uint32_t TOUCH_MENU_OPEN_TAP_SUPPRESS_MS = 1200;
inline void noteInkHUDUserInteraction()
{
// Keep power state and screen-timeout behavior in sync with InkHUD input activity.
powerFSM.trigger(EVENT_INPUT);
}
} // namespace
InkHUD::Events::Events()
{
// Get convenient references
@@ -38,6 +52,8 @@ void InkHUD::Events::begin()
void InkHUD::Events::onButtonShort()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
@@ -74,6 +90,8 @@ void InkHUD::Events::onButtonShort()
void InkHUD::Events::onButtonLong()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Slightly longer than playChirp
playBoop();
@@ -102,193 +120,295 @@ void InkHUD::Events::onButtonLong()
void InkHUD::Events::onExitShort()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Preserve legacy behavior on non-touch builds:
// EXIT input is only active when joystick mode is enabled.
// Touch-capable builds intentionally bypass this joystick gate.
if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) {
return;
}
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification module (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is change tiles
if (consumer)
consumer->onExitShort();
else if (!dismissedExt) { // Don't change tile if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
// Always let active system applets consume EXIT/HOME input (menu close, keyboard cancel, etc),
// including on touch-first nodes where joystick mode is disabled.
if (consumer) {
consumer->onExitShort();
return;
}
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT))
userConsumer->onExitShort();
else
inkhud->nextTile();
// Touch-capable InkHUD nodes use EXIT/HOME as a quick app switcher launcher.
if (!dismissedExt && inkhud->hasTouchEnabledProvider()) {
inkhud->openAppSwitcher();
return;
}
if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT)) {
userConsumer->onExitShort();
} else if (settings->joystick.enabled) {
// Preserve existing joystick behavior when no applet handles EXIT.
inkhud->nextTile();
}
}
}
void InkHUD::Events::onExitLong()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Slightly longer than playChirp
playBoop();
// Preserve legacy behavior on non-touch builds:
// EXIT input is only active when joystick mode is enabled.
// Touch-capable builds intentionally bypass this joystick gate.
if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) {
return;
}
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Slightly longer than playChirp
playBoop();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onExitLong();
else {
Applet *userConsumer = inkhud->getActiveApplet();
// Always allow system applets to consume EXIT/HOME long-press.
if (consumer) {
consumer->onExitLong();
} else {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG))
userConsumer->onExitLong();
// Nothing uses exit long yet
}
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG))
userConsumer->onExitLong();
// Nothing uses exit long yet
}
}
void InkHUD::Events::onNavUp()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavUp();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP))
userConsumer->onNavUp();
}
}
if (settings->joystick.enabled)
onTouchNavUp();
}
void InkHUD::Events::onNavDown()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavDown();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN))
userConsumer->onNavDown();
}
}
if (settings->joystick.enabled)
onTouchNavDown();
}
void InkHUD::Events::onNavLeft()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavLeft();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT))
userConsumer->onNavLeft();
else
inkhud->prevApplet();
}
}
if (settings->joystick.enabled)
onTouchNavLeft();
}
void InkHUD::Events::onNavRight()
{
if (settings->joystick.enabled) {
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
if (settings->joystick.enabled)
onTouchNavRight();
}
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
void InkHUD::Events::onTouchNavUp()
{
noteInkHUDUserInteraction();
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavRight();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
userConsumer->onNavRight();
else
inkhud->nextApplet();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavUp();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP))
userConsumer->onNavUp();
}
}
void InkHUD::Events::onTouchNavDown()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
if (consumer)
consumer->onNavDown();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN))
userConsumer->onNavDown();
}
}
void InkHUD::Events::onTouchNavLeft()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavLeft();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT))
userConsumer->onNavLeft();
else
inkhud->prevApplet();
}
}
void InkHUD::Events::onTouchNavRight()
{
noteInkHUDUserInteraction();
// Audio feedback (via buzzer)
// Short tone
playChirp();
// Cancel any beeping, buzzing, blinking
// Some button handling suppressed if we are dismissing an external notification (see below)
bool dismissedExt = dismissExternalNotification();
// Check which system applet wants to handle the button press (if any)
SystemApplet *consumer = nullptr;
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
consumer = sa;
break;
}
}
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavRight();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
userConsumer->onNavRight();
else
inkhud->nextApplet();
}
}
void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress)
{
const bool touchEnabledBuild = inkhud->hasTouchEnabledProvider();
// A long-press used to open the menu can be followed by a synthetic/queued tap at release.
// Ignore that brief follow-up window so touch-opened menus do not auto-select an item.
if (touchEnabledBuild && !longPress && suppressTouchTapUntilMs != 0) {
if ((int32_t)(millis() - suppressTouchTapUntilMs) < 0) {
noteInkHUDUserInteraction();
return;
}
suppressTouchTapUntilMs = 0;
}
// Give system applets (menu, keyboard, etc) first chance to consume direct touch input.
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput && sa->onTouchPoint(x, y, longPress)) {
noteInkHUDUserInteraction();
return;
}
}
// In split layouts, tapping a different tile changes focus.
// Consume this tap so selection does not also trigger button fallback behavior.
if (inkhud->selectTileAt(x, y)) {
noteInkHUDUserInteraction();
return;
}
// Allow foreground user applet to consume direct touch input if it wants.
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->onTouchPoint(x, y, longPress)) {
noteInkHUDUserInteraction();
return;
}
// Fallback to existing button semantics so non-touch-aware applets keep working unchanged.
if (longPress) {
onButtonLong();
// Only arm suppression if the long-press actually opened menu foreground.
SystemApplet *menu = inkhud->getSystemApplet("Menu");
if (touchEnabledBuild && menu && menu->isForeground()) {
suppressTouchTapUntilMs = millis() + TOUCH_MENU_OPEN_TAP_SUPPRESS_MS;
}
} else
onButtonShort();
}
void InkHUD::Events::onFreeText(char c)
{
noteInkHUDUserInteraction();
// Trigger the first system applet that wants to handle the new character
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -300,6 +420,8 @@ void InkHUD::Events::onFreeText(char c)
void InkHUD::Events::onFreeTextDone()
{
noteInkHUDUserInteraction();
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -311,6 +433,8 @@ void InkHUD::Events::onFreeTextDone()
void InkHUD::Events::onFreeTextCancel()
{
noteInkHUDUserInteraction();
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -487,4 +611,4 @@ bool InkHUD::Events::dismissExternalNotification()
return true;
}
#endif
#endif
+16 -7
View File
@@ -17,6 +17,7 @@ however this class handles general events which concern InkHUD as a whole, e.g.
#include "./InkHUD.h"
#include "./Persistence.h"
#include <stdint.h>
namespace NicheGraphics::InkHUD
{
@@ -30,12 +31,17 @@ class Events
void onButtonShort(); // User button: short press
void onButtonLong(); // User button: long press
void applyingChanges();
void onExitShort(); // Exit button: short press
void onExitLong(); // Exit button: long press
void onNavUp(); // Navigate up
void onNavDown(); // Navigate down
void onNavLeft(); // Navigate left
void onNavRight(); // Navigate right
void onExitShort(); // Exit button: short press
void onExitLong(); // Exit button: long press
void onNavUp(); // Navigate up
void onNavDown(); // Navigate down
void onNavLeft(); // Navigate left
void onNavRight(); // Navigate right
void onTouchNavUp(); // Navigate up from touch input
void onTouchNavDown(); // Navigate down from touch input
void onTouchNavLeft(); // Navigate left from touch input
void onTouchNavRight(); // Navigate right from touch input
void onTouchTap(uint16_t x, uint16_t y, bool longPress); // Touch tap/long-press with coordinates
// Free text typing events
void onFreeText(char c); // New freetext character input
@@ -79,8 +85,11 @@ class Events
// If set, InkHUD's data will be erased during onReboot
bool eraseOnReboot = false;
// Suppress follow-up tap generated immediately after a touch long-press opens menu.
uint32_t suppressTouchTapUntilMs = 0;
};
} // namespace NicheGraphics::InkHUD
#endif
#endif
+121 -1
View File
@@ -73,6 +73,24 @@ void InkHUD::InkHUD::begin()
// LogoApplet shows boot screen here
}
void InkHUD::InkHUD::setTouchEnabledProvider(TouchEnabledProvider provider)
{
touchEnabledProvider = provider;
}
bool InkHUD::InkHUD::hasTouchEnabledProvider() const
{
return touchEnabledProvider != nullptr;
}
bool InkHUD::InkHUD::isTouchEnabled() const
{
if (!touchEnabledProvider)
return true;
return touchEnabledProvider();
}
// Call this when your user button gets a short press
// Should be connected to an input source in nicheGraphics.h (NicheGraphics::Inputs::TwoButton?)
void InkHUD::InkHUD::shortpress()
@@ -175,6 +193,92 @@ void InkHUD::InkHUD::navRight()
}
}
// Call this when touch input needs joystick-like up navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavUp()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavLeft();
break;
case 2: // 180 deg
events->onTouchNavDown();
break;
case 3: // 270 deg
events->onTouchNavRight();
break;
default: // 0 deg
events->onTouchNavUp();
break;
}
}
// Call this when touch input needs joystick-like down navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavDown()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavRight();
break;
case 2: // 180 deg
events->onTouchNavUp();
break;
case 3: // 270 deg
events->onTouchNavLeft();
break;
default: // 0 deg
events->onTouchNavDown();
break;
}
}
// Call this when touch input needs joystick-like left navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavLeft()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavDown();
break;
case 2: // 180 deg
events->onTouchNavRight();
break;
case 3: // 270 deg
events->onTouchNavUp();
break;
default: // 0 deg
events->onTouchNavLeft();
break;
}
}
// Call this when touch input needs joystick-like right navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavRight()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavUp();
break;
case 2: // 180 deg
events->onTouchNavLeft();
break;
case 3: // 270 deg
events->onTouchNavDown();
break;
default: // 0 deg
events->onTouchNavRight();
break;
}
}
void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y)
{
events->onTouchTap(x, y, false);
}
void InkHUD::InkHUD::touchLongPress(uint16_t x, uint16_t y)
{
events->onTouchTap(x, y, true);
}
// Call this for keyboard input
// The Keyboard Applet also calls this
void InkHUD::InkHUD::freeText(char c)
@@ -223,6 +327,12 @@ void InkHUD::InkHUD::openMenu()
windowManager->openMenu();
}
// Show touch-friendly app switcher (on the focused tile)
void InkHUD::InkHUD::openAppSwitcher()
{
windowManager->openAppSwitcher();
}
// Bring AlignStick applet to the foreground
void InkHUD::InkHUD::openAlignStick()
{
@@ -255,6 +365,16 @@ void InkHUD::InkHUD::prevTile()
windowManager->prevTile();
}
bool InkHUD::InkHUD::showApplet(uint8_t appletIndex)
{
return windowManager->showApplet(appletIndex);
}
bool InkHUD::InkHUD::selectTileAt(uint16_t x, uint16_t y)
{
return windowManager->selectTileAt(x, y);
}
// Rotate the display image by 90 degrees
void InkHUD::InkHUD::rotate()
{
@@ -376,4 +496,4 @@ void InkHUD::InkHUD::drawPixel(int16_t x, int16_t y, Color c)
renderer->handlePixel(x, y, c);
}
#endif
#endif
+17
View File
@@ -39,6 +39,8 @@ class WindowManager;
class InkHUD
{
public:
using TouchEnabledProvider = bool (*)();
static InkHUD *getInstance(); // Access to this singleton class
// Configuration
@@ -51,6 +53,11 @@ 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
@@ -62,6 +69,12 @@ 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);
@@ -76,11 +89,14 @@ 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();
@@ -129,6 +145,7 @@ 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
+111 -2
View File
@@ -3,11 +3,13 @@
#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"
@@ -15,6 +17,14 @@
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
@@ -132,6 +142,38 @@ 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()
@@ -140,6 +182,18 @@ 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()
{
@@ -151,7 +205,7 @@ void InkHUD::WindowManager::openAlignStick()
void InkHUD::WindowManager::openKeyboard()
{
if (!settings->joystick.enabled || inkhud->twoWayRocker)
if (!supportsOnScreenKeyboard(inkhud, settings))
return;
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
@@ -165,7 +219,7 @@ void InkHUD::WindowManager::openKeyboard()
void InkHUD::WindowManager::closeKeyboard()
{
if (!settings->joystick.enabled || inkhud->twoWayRocker)
if (!supportsOnScreenKeyboard(inkhud, settings))
return;
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
@@ -279,6 +333,41 @@ 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()
{
@@ -485,14 +574,21 @@ 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);
@@ -511,6 +607,8 @@ 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()
@@ -527,6 +625,17 @@ 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
+4 -1
View File
@@ -29,13 +29,16 @@ 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();
@@ -76,4 +79,4 @@ class WindowManager
} // namespace NicheGraphics::InkHUD
#endif
#endif
+3
View File
@@ -390,8 +390,11 @@ 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
+4 -1
View File
@@ -1,5 +1,8 @@
#pragma once
#if ARCH_PORTDUINO
// 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__)
#include "InputBroker.h"
#include "concurrency/OSThread.h"
#include <assert.h>
+2 -1
View File
@@ -1,4 +1,5 @@
#ifdef ARCH_PORTDUINO
// Linux evdev impl. Same Linux-only gating as LinuxInput.h.
#if defined(ARCH_PORTDUINO) && defined(__linux__)
#pragma once
#include "LinuxInput.h"
#include "main.h"
+51 -6
View File
@@ -9,6 +9,35 @@
#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
@@ -20,7 +49,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), _tapped(false), _originName(name)
_last_y(0), _start(0), _lastTouchSeenMs(0), _tapped(false), _originName(name)
{
}
@@ -28,7 +57,7 @@ void TouchScreenBase::init(bool hasTouch)
{
if (hasTouch) {
LOG_INFO("TouchScreen initialized %d %d", TOUCH_THRESHOLD_X, TOUCH_THRESHOLD_Y);
this->setInterval(100);
this->setInterval(TOUCH_POLL_INTERVAL_IDLE);
} else {
disable();
this->setInterval(UINT_MAX);
@@ -39,6 +68,8 @@ 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;
@@ -46,9 +77,13 @@ 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) {
this->setInterval(20);
_lastTouchSeenMs = millis();
this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_ACTIVE_FAST : TOUCH_POLL_INTERVAL_ACTIVE);
_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) {
@@ -62,7 +97,7 @@ int32_t TouchScreenBase::runOnce()
time_t duration = millis() - _start;
x = _last_x;
y = _last_y;
this->setInterval(50);
this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_RELEASE_FAST : TOUCH_POLL_INTERVAL_RELEASE);
// compute distance
int16_t dx = x - _first_x;
@@ -92,7 +127,7 @@ int32_t TouchScreenBase::runOnce()
}
// tap
else {
if (duration > 0 && duration < TIME_LONG_PRESS) {
if (duration > 0 && (duration < TIME_LONG_PRESS || !allowLongPress)) {
if (_tapped) {
_tapped = false;
} else {
@@ -132,7 +167,7 @@ int32_t TouchScreenBase::runOnce()
#endif
// fire LONG_PRESS event without the need for release
if (touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) {
if (allowLongPress && 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);
@@ -157,3 +192,13 @@ void TouchScreenBase::hapticFeedback()
drv.go();
#endif
}
bool TouchScreenBase::fastTapModeEnabled() const
{
return false;
}
bool TouchScreenBase::longPressEnabled() const
{
return true;
}
+3
View File
@@ -35,6 +35,8 @@ 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;
@@ -49,6 +51,7 @@ 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;
+32 -1
View File
@@ -3,6 +3,12 @@
#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"
@@ -39,6 +45,31 @@ 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
*
@@ -83,4 +114,4 @@ void TouchScreenImpl1::onEvent(const TouchEvent &event)
return;
}
this->notifyObservers(&e);
}
}
+2
View File
@@ -10,6 +10,8 @@ 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 *);
};
+16 -3
View File
@@ -19,8 +19,12 @@ 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
@@ -48,8 +52,11 @@ bool mixWithLoRaEntropy(uint8_t *buffer, size_t length)
// and return false so callers know no extra mixing occurred.
RadioLibInterface *radio = RadioLibInterface::instance;
if (!radio) {
// Intentionally silent: this path runs during portduinoSetup() before the
// console/SerialConsole is initialized, so LOG_* here would dereference a null pointer.
// 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");
}
return false;
}
@@ -116,10 +123,16 @@ 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);
+5 -2
View File
@@ -6,8 +6,11 @@
#include "configuration.h"
#include "detect/LoRaRadioType.h"
// Sentinel marking the end of a modem preset array
static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END =
// 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 =
static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(0xFF);
// Region profile: bundles the preset list with regulatory parameters shared across regions
+1 -1
View File
@@ -119,7 +119,7 @@ bool RadioLibInterface::canSendImmediately()
return true;
}
bool RadioLibInterface::receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag)
bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long 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, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag);
bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag);
/** Do any hardware setup needed on entry into send configuration for the radio.
* Subclasses can customize, but must also call this base method */
+7 -4
View File
@@ -736,9 +736,13 @@ 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 encrypted packet for MQTT
// 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).
DEBUG_HEAP_BEFORE;
p_encrypted = packetPool.allocCopy(*p);
meshtastic_MeshPacket *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
@@ -832,8 +836,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
#endif
}
packetPool.release(p_encrypted); // Release the encrypted packet
p_encrypted = nullptr;
packetPool.release(p_encrypted); // Release the encrypted packet (release() handles nullptr)
}
void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
-3
View File
@@ -92,9 +92,6 @@ 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;
+5
View File
@@ -1,6 +1,7 @@
#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)
{
@@ -82,8 +83,10 @@ 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;
@@ -102,8 +105,10 @@ 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;
+30 -6
View File
@@ -289,7 +289,14 @@ 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
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;
/* Standard predefined channel settings
@@ -319,7 +326,24 @@ 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
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;
typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode {
@@ -706,12 +730,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_ITU23_2M
#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M+1))
#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_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST
#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_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_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
+2
View File
@@ -315,6 +315,8 @@ typedef enum _meshtastic_HardwareModel {
meshtastic_HardwareModel_THINKNODE_M7 = 129,
meshtastic_HardwareModel_THINKNODE_M8 = 130,
meshtastic_HardwareModel_THINKNODE_M9 = 131,
/* The Heltec-V4-R8 uses an ESP32S3R8 chip, plus an SX1262. */
meshtastic_HardwareModel_HELTEC_V4_R8 = 132,
/* ------------------------------------------------------------------------------------------------------------------------------------------
Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits.
------------------------------------------------------------------------------------------------------------------------------------------ */
+89
View File
@@ -117,4 +117,93 @@ size_t pb_string_length(const char *str, size_t max_len)
}
}
return len;
}
bool sanitizeUtf8(char *buf, size_t bufSize)
{
if (!buf || bufSize == 0)
return false;
// Ensure null-terminated within buffer; report if we had to enforce it
bool replaced = (buf[bufSize - 1] != '\0');
buf[bufSize - 1] = '\0';
size_t i = 0;
size_t len = strlen(buf);
while (i < len) {
uint8_t b = (uint8_t)buf[i];
// Determine expected sequence length from lead byte
size_t seqLen;
uint32_t minCodepoint;
if (b <= 0x7F) {
// ASCII — valid single byte
i++;
continue;
} else if ((b & 0xE0) == 0xC0) {
seqLen = 2;
minCodepoint = 0x80; // Reject overlong
} else if ((b & 0xF0) == 0xE0) {
seqLen = 3;
minCodepoint = 0x800;
} else if ((b & 0xF8) == 0xF0) {
seqLen = 4;
minCodepoint = 0x10000;
} else {
// Invalid lead byte (0x80-0xBF or 0xF8+)
buf[i] = '?';
replaced = true;
i++;
continue;
}
// Check that we have enough bytes remaining
if (i + seqLen > len) {
// Truncated sequence at end of string — replace remaining bytes
for (size_t j = i; j < len; j++) {
buf[j] = '?';
}
replaced = true;
break;
}
// Validate continuation bytes (must be 10xxxxxx)
bool valid = true;
for (size_t j = 1; j < seqLen; j++) {
if (((uint8_t)buf[i + j] & 0xC0) != 0x80) {
valid = false;
break;
}
}
if (valid) {
// Decode codepoint to check for overlong encodings and surrogates
uint32_t cp = 0;
if (seqLen == 2)
cp = b & 0x1F;
else if (seqLen == 3)
cp = b & 0x0F;
else
cp = b & 0x07;
for (size_t j = 1; j < seqLen; j++)
cp = (cp << 6) | ((uint8_t)buf[i + j] & 0x3F);
if (cp < minCodepoint || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
// Overlong encoding, out of Unicode range, or surrogate half
valid = false;
}
}
if (valid) {
i += seqLen;
} else {
// Replace only the lead byte; continuation bytes will be caught on next iteration
buf[i] = '?';
replaced = true;
i++;
}
}
return replaced;
}
+4
View File
@@ -56,6 +56,10 @@ const std::string vformat(const char *const zcFormat, ...);
// Get actual string length for nanopb char array fields.
size_t pb_string_length(const char *str, size_t max_len);
// Sanitize a fixed-size char buffer in-place by replacing invalid UTF-8 sequences with '?'.
// Ensures the result is null-terminated within bufSize. Returns true if any bytes were replaced.
bool sanitizeUtf8(char *buf, size_t bufSize);
/// Calculate 2^n without calling pow() - used for spreading factor and other calculations
inline uint32_t pow_of_2(uint32_t n)
{
+8
View File
@@ -626,10 +626,14 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
if (*o.long_name) {
changed |= strcmp(owner.long_name, o.long_name);
strncpy(owner.long_name, o.long_name, sizeof(owner.long_name));
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
sanitizeUtf8(owner.long_name, sizeof(owner.long_name));
}
if (*o.short_name) {
changed |= strcmp(owner.short_name, o.short_name);
strncpy(owner.short_name, o.short_name, sizeof(owner.short_name));
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
sanitizeUtf8(owner.short_name, sizeof(owner.short_name));
}
snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum());
@@ -1430,7 +1434,11 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
// Set call sign and override lora limitations for licensed use
strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name));
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
sanitizeUtf8(owner.long_name, sizeof(owner.long_name));
strncpy(owner.short_name, p.short_name, sizeof(owner.short_name));
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
sanitizeUtf8(owner.short_name, sizeof(owner.short_name));
owner.is_licensed = true;
config.lora.override_duty_cycle = true;
config.lora.tx_power = p.tx_power;
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+1 -1
View File
@@ -32,7 +32,7 @@
#include <assert.h>
#include <utility>
#include <IPAddress.h>
#include "IPAddress.h"
#if defined(ARCH_PORTDUINO)
#include <netinet/in.h>
#elif !defined(ntohl)
@@ -5,12 +5,17 @@
#include "Observer.h"
#include "TouchDrvGT911.hpp"
#include "Wire.h"
#include "buzz.h"
#include "concurrency/OSThread.h"
#include "input/InputBroker.h"
#include "input/TouchScreenImpl1.h"
#include "main.h"
#include "sleep.h"
#include <cstring>
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/Persistence.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
// Bridges touch events from TouchScreenImpl1 directly into InkHUD,
@@ -18,8 +23,7 @@
// Routing mirrors the mini-epaper-s3 two-way rocker pattern:
// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu)
// - Nav up/down: navUp/navDown always (menu scroll)
// - Tap: shortpress (cycle applets / confirm in menu)
// - Long press: longpress (open menu / back)
// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics)
class TouchInkHUDBridge : public Observer<const InputEvent *>
{
int onNotify(const InputEvent *e) override
@@ -41,28 +45,28 @@ class TouchInkHUDBridge : public Observer<const InputEvent *>
switch (e->inputEvent) {
case INPUT_BROKER_USER_PRESS:
inkhud->shortpress();
inkhud->touchTap(e->touchX, e->touchY);
break;
case INPUT_BROKER_SELECT:
inkhud->longpress();
inkhud->touchLongPress(e->touchX, e->touchY);
break;
case INPUT_BROKER_LEFT:
if (systemHandlingInput)
inkhud->navUp();
inkhud->touchNavUp();
else
inkhud->prevApplet();
break;
case INPUT_BROKER_RIGHT:
if (systemHandlingInput)
inkhud->navDown();
inkhud->touchNavDown();
else
inkhud->nextApplet();
break;
case INPUT_BROKER_UP:
inkhud->navUp();
inkhud->touchNavUp();
break;
case INPUT_BROKER_DOWN:
inkhud->navDown();
inkhud->touchNavDown();
break;
default:
break;
@@ -76,6 +80,430 @@ static TouchInkHUDBridge touchBridge;
TouchDrvGT911 touch;
namespace
{
constexpr uint8_t BACKLIGHT_ON_LEVEL = HIGH;
constexpr uint8_t BACKLIGHT_OFF_LEVEL = LOW;
volatile bool backlightUserEnabled = true;
volatile bool backlightForcedByTimeout = false;
volatile bool backlightForcedBySleep = false;
void applyBacklightState()
{
const bool shouldOn = backlightUserEnabled && !backlightForcedByTimeout && !backlightForcedBySleep;
digitalWrite(BOARD_BL_EN, shouldOn ? BACKLIGHT_ON_LEVEL : BACKLIGHT_OFF_LEVEL);
}
volatile bool touchInputEnabled = true;
volatile bool touchForcedByTimeout = false;
volatile bool touchControllerReady = false;
volatile bool touchLightSleepActive = false;
volatile bool touchNeedsWake = false;
volatile bool touchIndicatorRefreshPending = false;
volatile uint32_t touchResumeBlockUntilMs = 0;
volatile uint32_t touchStateEpoch = 1;
volatile bool homeCapButtonEventsEnabled = false;
#if HAS_SCREEN
uint32_t lastTouchIndicatorMs = 0;
#endif
void showTouchIndicator(const char *text)
{
#if HAS_SCREEN
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
// InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of popup banners.
(void)text;
return;
#else
// Keep repeated notifications low profile and non-spammy.
if ((millis() - lastTouchIndicatorMs) < 500) {
return;
}
lastTouchIndicatorMs = millis();
if (screen) {
screen->showSimpleBanner(text, 1400);
}
#endif
#else
(void)text;
#endif
}
#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK)
bool readPca9535Port1(uint8_t *value)
{
if (!value) {
return false;
}
Wire.beginTransmission(BOARD_PCA9535_ADDR);
Wire.write((uint8_t)0x01); // input port 1
if (Wire.endTransmission(false) != 0) {
return false;
}
if (Wire.requestFrom((uint8_t)BOARD_PCA9535_ADDR, (uint8_t)1) != 1) {
return false;
}
*value = Wire.read();
return true;
}
bool isPca9535SideKeyPressed()
{
uint8_t port1 = 0xFF;
if (!readPca9535Port1(&port1)) {
return false;
}
return (port1 & BOARD_PCA9535_BUTTON_MASK) == 0;
}
class SideKeyInterruptThread : public concurrency::OSThread
{
public:
SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", SAMPLE_MS)
{
// Do not run unless an edge arrives.
OSThread::disable();
instance = this;
#ifdef ARCH_ESP32
lsObserver.observe(&notifyLightSleep);
lsEndObserver.observe(&notifyLightSleepEnd);
#endif
}
void begin()
{
pinMode(BOARD_PCA9535_INT, INPUT_PULLUP);
attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING);
}
protected:
int32_t runOnce() override
{
const uint32_t now = millis();
if (now < touchResumeBlockUntilMs) {
resetStateAndStop();
return OSThread::disable();
}
if (touchLightSleepActive) {
resetStateAndStop();
return OSThread::disable();
}
// Ignore side-key handling while BOOT/user button is held.
if (digitalRead(BUTTON_PIN) == LOW) {
resetStateAndStop();
return OSThread::disable();
}
switch (state) {
case State::IRQ_PENDING:
// Initial debounce after expander interrupt edge.
if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) {
return SAMPLE_MS;
}
if (isPca9535SideKeyPressed()) {
state = State::PRESSED;
pressStartMs = now;
return SAMPLE_MS;
}
// Spurious/cleared edge.
resetStateAndStop();
return OSThread::disable();
case State::PRESSED: {
if (isPca9535SideKeyPressed()) {
// Fire long-press action as soon as threshold is reached, without waiting for release.
if (!longPressFired && (uint32_t)(now - pressStartMs) >= LONG_PRESS_MIN_MS &&
(uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) {
t5BacklightToggleUser();
longPressFired = true;
lastActionMs = now;
}
return SAMPLE_MS;
}
// Released: if long-press already fired, do nothing. Otherwise classify short press.
const uint32_t heldMs = now - pressStartMs;
if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) {
// If timeout forced touch/backlight off, short-press acts as a wake action first.
if (t5TouchIsForcedByTimeout()) {
t5TouchHandleUserInput();
t5BacklightHandleUserInput();
} else {
toggleTouchInputEnabled();
}
lastActionMs = now;
}
resetStateAndStop();
return OSThread::disable();
}
case State::REST:
default:
return OSThread::disable();
}
}
private:
enum class State : uint8_t {
REST,
IRQ_PENDING,
PRESSED,
};
static constexpr uint32_t SAMPLE_MS = 15;
static constexpr uint32_t DEBOUNCE_MS = 25;
static constexpr uint32_t SHORT_PRESS_MIN_MS = 30;
static constexpr uint32_t LONG_PRESS_MIN_MS = 450;
static constexpr uint32_t ACTION_COOLDOWN_MS = 180;
static SideKeyInterruptThread *instance;
static void isr()
{
if (instance) {
instance->onInterruptEdge();
}
}
void onInterruptEdge()
{
if (touchLightSleepActive) {
return;
}
const uint32_t now = millis();
if (now < touchResumeBlockUntilMs) {
return;
}
if (state != State::REST) {
return;
}
state = State::IRQ_PENDING;
irqAtMs = millis();
startThread();
}
void startThread()
{
if (!OSThread::enabled) {
OSThread::setIntervalFromNow(0);
OSThread::enabled = true;
runASAP = true;
}
}
void resetStateAndStop()
{
state = State::REST;
longPressFired = false;
if (OSThread::enabled) {
OSThread::disable();
}
}
#ifdef ARCH_ESP32
int onLightSleep(void *)
{
detachInterrupt(BOARD_PCA9535_INT);
// Clear any latched PCA9535 interrupt before enabling GPIO wake.
// If INT is left asserted low, light sleep exits immediately.
uint8_t ignored = 0xFF;
(void)readPca9535Port1(&ignored);
resetStateAndStop();
return 0;
}
int onLightSleepEnd(esp_sleep_wakeup_cause_t cause)
{
(void)cause;
// Consume any pending interrupt source before reattaching ISR.
uint8_t ignored = 0xFF;
(void)readPca9535Port1(&ignored);
pinMode(BOARD_PCA9535_INT, INPUT_PULLUP);
attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING);
return 0;
}
CallbackObserver<SideKeyInterruptThread, void *> lsObserver{this, &SideKeyInterruptThread::onLightSleep};
CallbackObserver<SideKeyInterruptThread, esp_sleep_wakeup_cause_t> lsEndObserver{this,
&SideKeyInterruptThread::onLightSleepEnd};
#endif
volatile State state = State::REST;
volatile uint32_t irqAtMs = 0;
uint32_t pressStartMs = 0;
bool longPressFired = false;
uint32_t lastActionMs = 0;
};
SideKeyInterruptThread *SideKeyInterruptThread::instance = nullptr;
SideKeyInterruptThread *sideKeyThread = nullptr;
#endif
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
void refreshTouchIndicatorInInkHUD(bool async = true)
{
auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance();
NicheGraphics::InkHUD::SystemApplet *touchStatus = nullptr;
for (auto *sa : inkhud->systemApplets) {
if (sa && sa->name && strcmp(sa->name, "TouchStatus") == 0) {
touchStatus = sa;
break;
}
}
if (touchStatus) {
if (inkhud->isTouchEnabled())
touchStatus->sendToBackground();
else
touchStatus->bringToForeground();
}
// Re-render all applets so touch-status visibility changes are immediately reflected.
inkhud->forceUpdate(NicheGraphics::Drivers::EInk::UpdateTypes::FAST, true, async);
}
#endif
} // namespace
void t5BacklightSetUserEnabled(bool enabled)
{
backlightUserEnabled = enabled;
if (enabled) {
// Manual ON should release auto-off gates.
backlightForcedByTimeout = false;
backlightForcedBySleep = false;
}
applyBacklightState();
}
bool t5BacklightIsUserEnabled()
{
return backlightUserEnabled;
}
void t5BacklightToggleUser()
{
t5BacklightSetUserEnabled(!backlightUserEnabled);
}
void t5BacklightSetForcedByTimeout(bool forced)
{
backlightForcedByTimeout = forced;
applyBacklightState();
}
void t5BacklightSetForcedBySleep(bool forced)
{
backlightForcedBySleep = forced;
applyBacklightState();
}
void t5BacklightHandleUserInput()
{
// Screen-timeout should be lifted by direct user interaction.
backlightForcedByTimeout = false;
applyBacklightState();
}
void t5TouchSetForcedByTimeout(bool forced)
{
touchForcedByTimeout = forced;
touchStateEpoch++;
touchIndicatorRefreshPending = true;
if (forced) {
// While timeout-forced, keep controller asleep to avoid stale IRQ chatter.
touchNeedsWake = false;
if (touchControllerReady && !touchLightSleepActive) {
touch.sleep();
}
} else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) {
// Defer wake until readTouch() so I2C settles post-state transition.
touchNeedsWake = true;
}
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
if (!touchLightSleepActive) {
refreshTouchIndicatorInInkHUD();
touchIndicatorRefreshPending = false;
}
#endif
}
bool t5TouchIsForcedByTimeout()
{
return touchForcedByTimeout;
}
void t5TouchHandleUserInput()
{
t5TouchSetForcedByTimeout(false);
}
void t5SetHomeCapButtonEventsEnabled(bool enabled)
{
homeCapButtonEventsEnabled = enabled;
}
bool isTouchInputEnabled()
{
return touchInputEnabled && !touchForcedByTimeout && !touchLightSleepActive;
}
void setTouchInputEnabled(bool enabled, bool showIndicator)
{
if (touchInputEnabled == enabled) {
LOG_DEBUG("touchscreen1: setTouchInputEnabled no-op en=%d", enabled);
return;
}
LOG_DEBUG("touchscreen1: setTouchInputEnabled %d -> %d (showIndicator=%d)", touchInputEnabled, enabled, showIndicator);
touchInputEnabled = enabled;
touchStateEpoch++;
if (enabled) {
touchNeedsWake = touchControllerReady;
if (touchControllerReady && !touchLightSleepActive) {
LOG_DEBUG("touchscreen1: wakeup() on enable");
touch.wakeup();
touchNeedsWake = false;
}
} else {
touchNeedsWake = false;
if (touchControllerReady && !touchLightSleepActive) {
LOG_DEBUG("touchscreen1: sleep() on disable");
touch.sleep();
}
if (showIndicator) {
showTouchIndicator("Touch OFF");
touchIndicatorRefreshPending = true;
}
}
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
if (showIndicator && !touchLightSleepActive) {
refreshTouchIndicatorInInkHUD();
touchIndicatorRefreshPending = false;
}
#endif
}
void toggleTouchInputEnabled()
{
setTouchInputEnabled(!touchInputEnabled, true);
}
// Commands the GT911 into standby before the Wire bus is torn down.
// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here.
struct TouchDeepSleepObserver {
@@ -87,8 +515,96 @@ struct TouchDeepSleepObserver {
CallbackObserver<TouchDeepSleepObserver, void *> observer{this, &TouchDeepSleepObserver::onDeepSleep};
} static touchDeepSleepObserver;
#ifdef ARCH_ESP32
struct TouchLightSleepObserver {
int onLightSleep(void *)
{
touchLightSleepActive = true;
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
// Render touch-off overlay before sleeping so user sees touch is unavailable.
touchIndicatorRefreshPending = true;
refreshTouchIndicatorInInkHUD(false);
touchIndicatorRefreshPending = false;
#endif
return 0;
}
CallbackObserver<TouchLightSleepObserver, void *> observer{this, &TouchLightSleepObserver::onLightSleep};
} static touchLightSleepObserver;
struct TouchLightSleepEndObserver {
int onLightSleepEnd(esp_sleep_wakeup_cause_t cause)
{
(void)cause;
touchLightSleepActive = false;
if (!touchControllerReady) {
return 0;
}
if (touchInputEnabled && !touchForcedByTimeout) {
touchNeedsWake = true;
} else {
touchNeedsWake = false;
}
touchStateEpoch++;
touchResumeBlockUntilMs = millis() + 150;
touchIndicatorRefreshPending = !isTouchInputEnabled();
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
// Clear sleep-time touch overlay after wake.
touchIndicatorRefreshPending = true;
refreshTouchIndicatorInInkHUD();
touchIndicatorRefreshPending = false;
#endif
return 0;
}
CallbackObserver<TouchLightSleepEndObserver, esp_sleep_wakeup_cause_t> observer{this,
&TouchLightSleepEndObserver::onLightSleepEnd};
} static touchLightSleepEndObserver;
#endif
bool readTouch(int16_t *x, int16_t *y)
{
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
static uint32_t suppressUntilMs = 0;
static uint32_t seenTouchStateEpoch = 0;
// Reset transient gesture helpers whenever touch mode changes.
if (seenTouchStateEpoch != touchStateEpoch) {
seenTouchStateEpoch = touchStateEpoch;
suppressUntilMs = 0;
}
// Let buses and peripherals settle briefly after light-sleep wake.
if (millis() < touchResumeBlockUntilMs) {
return false;
}
if (touchIndicatorRefreshPending) {
refreshTouchIndicatorInInkHUD();
touchIndicatorRefreshPending = false;
}
if (!isTouchInputEnabled()) {
return false;
}
if (touchNeedsWake && touchControllerReady) {
LOG_DEBUG("touchscreen1: wakeup() on deferred resume");
touch.wakeup();
touchNeedsWake = false;
suppressUntilMs = millis() + 60;
return false;
}
// After a recovery pulse, emit a brief "released" window so gesture state can reset.
if (suppressUntilMs != 0 && millis() < suppressUntilMs) {
return false;
}
#endif
if (!digitalRead(GT911_PIN_INT)) {
int16_t raw_x;
int16_t raw_y;
@@ -123,22 +639,71 @@ bool readTouch(int16_t *x, int16_t *y)
return true;
}
}
return false;
}
// T5-S3-ePaper Pro specific (late-) init
void lateInitVariant(void)
void variant_shutdown()
{
// Ensure backlight is off during deep sleep.
t5BacklightSetForcedBySleep(true);
}
void lateInitVariant()
{
touch.setPins(GT911_PIN_RST, GT911_PIN_INT);
if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) {
// Match LilyGO sample behavior: GT911 center/home capacitive key callback.
touch.setHomeButtonCallback(
[](void *user_data) {
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
if (!homeCapButtonEventsEnabled) {
return;
}
static uint32_t lastHomeMs = 0;
const uint32_t now = millis();
if ((uint32_t)(now - lastHomeMs) < 220) {
return; // debounce repeated key reports while still touched
}
lastHomeMs = now;
auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance();
if (inkhud) {
// Route through InkHUD EXIT/HOME path (menu close, etc).
inkhud->exitShort();
}
#else
(void)user_data;
#endif
},
nullptr);
touchControllerReady = true;
touchInputEnabled = true;
touchForcedByTimeout = false;
touchLightSleepActive = false;
touchStateEpoch++;
touchDeepSleepObserver.observer.observe(&notifyDeepSleep);
#ifdef ARCH_ESP32
touchLightSleepObserver.observer.observe(&notifyLightSleep);
touchLightSleepEndObserver.observer.observe(&notifyLightSleepEnd);
#endif
touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch);
touchScreenImpl1->init();
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
touchBridge.observe(touchScreenImpl1);
#endif
} else {
touchControllerReady = false;
LOG_ERROR("Failed to find touch controller!");
}
#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK)
// Start side-key interrupt handling after touch init is complete.
if (!sideKeyThread) {
sideKeyThread = new SideKeyInterruptThread();
sideKeyThread->begin();
}
#endif
}
#endif
+8 -3
View File
@@ -1,6 +1,7 @@
#include "NRF52Bluetooth.h"
#include "BLEDfuSecure.h"
#include "BluetoothCommon.h"
#include "HardwareRNG.h"
#include "PowerFSM.h"
#include "configuration.h"
#include "main.h"
@@ -272,9 +273,13 @@ void NRF52Bluetooth::setup()
Bluefruit.setTxPower(NRF52_BLE_TX_POWER);
#endif
if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) {
configuredPasskey = config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN
? config.bluetooth.fixed_pin
: random(100000, 999999);
if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) {
configuredPasskey = config.bluetooth.fixed_pin;
} else {
uint32_t hwrand = 0;
HardwareRNG::fill(reinterpret_cast<uint8_t *>(&hwrand), sizeof(hwrand));
configuredPasskey = hwrand % 900000u + 100000u;
}
auto pinString = std::to_string(configuredPasskey);
LOG_INFO("Bluetooth pin set to '%i'", configuredPasskey);
Bluefruit.Security.setPIN(pinString.c_str());
+13 -5
View File
@@ -9,13 +9,10 @@
#include "PortduinoGlue.h"
#include "SHA256.h"
#include "api/ServerAPI.h"
#include "linux/gpio/LinuxGPIOPin.h"
#include "meshUtils.h"
#include <ErriezCRC32.h>
#include <Utility.h>
#include <assert.h>
#include <bluetooth/bluetooth.h>
#include <bluetooth/hci.h>
#include <filesystem>
#include <fstream>
#include <iostream>
@@ -25,6 +22,12 @@
#include <sys/ioctl.h>
#include <unistd.h>
#ifdef PORTDUINO_LINUX_HARDWARE
#include "linux/gpio/LinuxGPIOPin.h"
#include <bluetooth/bluetooth.h>
#include <bluetooth/hci.h>
#endif
#ifdef PORTDUINO_LINUX_HARDWARE
#include <cxxabi.h>
#endif
@@ -130,9 +133,9 @@ void getMacAddr(uint8_t *dmac)
}
} else if (portduino_config.mac_address.length() > 11) {
MAC_from_string(portduino_config.mac_address, dmac);
exit;
return;
} else {
#ifdef PORTDUINO_LINUX_HARDWARE
struct hci_dev_info di = {0};
di.dev_id = 0;
bdaddr_t bdaddr;
@@ -152,6 +155,11 @@ void getMacAddr(uint8_t *dmac)
dmac[3] = di.bdaddr.b[2];
dmac[4] = di.bdaddr.b[1];
dmac[5] = di.bdaddr.b[0];
#else
// No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default;
// the caller can override via the --hwid CLI flag or the YAML config.
(void)dmac;
#endif
}
}
+7 -3
View File
@@ -5,6 +5,7 @@
#include "platform/portduino/PortduinoGlue.h"
#include <RadioLib.h>
#include <csignal>
#include <cstring>
#include <iostream>
#include <libpinedio-usb.h>
#include <unistd.h>
@@ -34,7 +35,7 @@ class Ch341Hal : public RadioLibHal
: RadioLibHal(PI_INPUT, PI_OUTPUT, PI_LOW, PI_HIGH, PI_RISING, PI_FALLING)
{
if (serial != "") {
strncpy(pinedio.serial_number, serial.c_str(), 8);
std::strncpy(pinedio.serial_number, serial.c_str(), 8);
pinedio_set_option(&pinedio, PINEDIO_OPTION_SEARCH_SERIAL, 1);
}
// LOG_INFO("USB Serial: %s", pinedio.serial_number);
@@ -59,8 +60,11 @@ class Ch341Hal : public RadioLibHal
void getSerialString(char *_serial, size_t len)
{
len = len > 8 ? 8 : len;
strncpy(_serial, pinedio.serial_number, len);
if (len == 0)
return;
size_t bytesCopied = (len - 1) < 8 ? (len - 1) : 8;
std::strncpy(_serial, pinedio.serial_number, bytesCopied);
_serial[bytesCopied] = '\0';
}
void getProductString(char *_product_string, size_t len)
-75
View File
@@ -28,54 +28,6 @@
#define NUM_CELLS 1
#endif
// Set the number of samples, it has an effect of increasing sensitivity in complex electromagnetic environment.
#ifndef BATTERY_SENSE_SAMPLES
#define BATTERY_SENSE_SAMPLES 15
#endif
#ifndef ADC_MULTIPLIER
#define ADC_MULTIPLIER 2.0
#endif
#ifdef EXT_PWR_DETECT
#ifndef EXT_PWR_DETECT_MODE
#define EXT_PWR_DETECT_MODE INPUT
// If using internal pull resistors, we can infer EXT_PWR_DETECT_VALUE
#elif EXT_PWR_DETECT_MODE == INPUT_PULLUP
#define EXT_PWR_DETECT_VALUE LOW
#elif EXT_PWR_DETECT_MODE == INPUT_PULLDOWN
#define EXT_PWR_DETECT_VALUE HIGH
#endif
#ifndef EXT_PWR_DETECT_VALUE
#define EXT_PWR_DETECT_VALUE HIGH
#endif
#endif
#ifdef EXT_CHRG_DETECT
#ifndef EXT_CHRG_DETECT_MODE
#define EXT_CHRG_DETECT_MODE INPUT
// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP
#define EXT_CHRG_DETECT_VALUE LOW
#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN
#define EXT_CHRG_DETECT_VALUE HIGH
#endif
#ifndef EXT_CHRG_DETECT_VALUE
#define EXT_CHRG_DETECT_VALUE HIGH
#endif
#endif
#ifndef DELAY_FOREVER
#define DELAY_FOREVER portMAX_DELAY
#endif
// NRF52 has AREF_VOLTAGE defined in architecture.h but
// make sure it's included. If something is wrong with NRF52
// definition - compilation will fail on missing definition
#if !defined(AREF_VOLTAGE) && !defined(ARCH_NRF52)
#define AREF_VOLTAGE 3.3
#endif
#ifdef BAT_MEASURE_ADC_UNIT
extern RTC_NOINIT_ATTR uint64_t RTC_reg_b;
#include "soc/sens_reg.h" // needed for adc pin reset
@@ -134,31 +86,6 @@ extern RAK9154Sensor rak9154Sensor;
extern XPowersLibInterface *PMU;
#endif
#ifndef HAS_PMU
// Copy of the base class defined in axp20x.h, to prevent an automatic wire.h dependency
class HasBatteryLevel
{
public:
/**
* Battery state of charge, from 0 to 100 or -1 for unknown
*/
virtual int getBatteryPercent() { return -1; }
/**
* The raw voltage of the battery or NAN if unknown
*/
virtual uint16_t getBattVoltage() { return 0; }
/**
* return true if there is a battery installed in this unit
*/
virtual bool isBatteryConnect() { return false; }
virtual bool isVbusIn() { return false; }
virtual bool isCharging() { return false; }
};
#endif
class Power : public concurrency::OSThread
{
@@ -183,8 +110,6 @@ class Power : public concurrency::OSThread
void attachPowerInterrupts();
void detachPowerInterrupts();
volatile bool pmu_irq = false;
protected:
meshtastic::PowerStatus *statusHandler;
+16 -4
View File
@@ -79,7 +79,7 @@ RTC_DATA_ATTR int bootCount = 0;
*/
void setCPUFast(bool on)
{
#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT
#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT && !defined(T_LORA_PAGER) && !defined(T_DECK)
if (isWifiAvailable()) {
/*
@@ -316,9 +316,14 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN
#ifdef HAS_PPM
if (PPM) {
LOG_INFO("PMM shutdown");
console->flush();
PPM->shutdown();
// BQ25896 PMIC shutdown is a hard power-off state.
// Only use it for "sleep forever" / explicit shutdown, because timed deep sleep
// must remain wakeable by RTC timer.
if (msecToWake == portMAX_DELAY) {
LOG_INFO("PPM shutdown");
console->flush();
PPM->shutdown();
}
}
#endif
@@ -425,6 +430,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r
#ifdef KB_INT
gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_LOW_LEVEL);
#endif
#ifdef BOARD_PCA9535_INT
// Side-key interrupt line from PCA9535 expander (active low).
gpio_wakeup_enable((gpio_num_t)BOARD_PCA9535_INT, GPIO_INTR_LOW_LEVEL);
#endif
#ifdef BUTTON_PIN
gpio_num_t pin = (gpio_num_t)(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN);
gpio_wakeup_enable(pin, GPIO_INTR_LOW_LEVEL);
@@ -472,6 +481,9 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r
#ifdef KB_INT
gpio_wakeup_disable((gpio_num_t)KB_INT);
#endif
#ifdef BOARD_PCA9535_INT
gpio_wakeup_disable((gpio_num_t)BOARD_PCA9535_INT);
#endif
#ifdef BUTTON_PIN
// Disable wake-on-button interrupt. Re-attach normal button-interrupts
gpio_wakeup_disable(pin);
+4
View File
@@ -303,6 +303,10 @@ void setup()
{
initializeTestEnvironment();
// Wait for portduino's millis() clock to start ticking before tests run
testDelay(10);
testDelay(2000);
UNITY_BEGIN();
RUN_TEST(test_setLastSentToMesh_stores_millis);
+197
View File
@@ -0,0 +1,197 @@
#include "meshUtils.h"
#include <cstring>
#include <unity.h>
void setUp(void) {}
void tearDown(void) {}
// --- Valid UTF-8 should pass through unchanged ---
void test_ascii_unchanged()
{
char buf[32] = "Hello World";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("Hello World", buf);
}
void test_valid_2byte_unchanged()
{
// "café" — é is C3 A9
char buf[16] = "caf\xC3\xA9";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("caf\xC3\xA9", buf);
}
void test_valid_3byte_unchanged()
{
// "€" is E2 82 AC
char buf[16] = "\xE2\x82\xAC";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("\xE2\x82\xAC", buf);
}
void test_valid_4byte_emoji_unchanged()
{
// 🌙 is F0 9F 8C 99
char buf[16] = "\xF0\x9F\x8C\x99";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("\xF0\x9F\x8C\x99", buf);
}
void test_valid_mixed_unchanged()
{
// "Hi 🌙!" — mix of ASCII and 4-byte
char buf[16] = "Hi \xF0\x9F\x8C\x99!";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("Hi \xF0\x9F\x8C\x99!", buf);
}
void test_empty_string()
{
char buf[8] = "";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("", buf);
}
// --- Invalid sequences observed in the wild ---
void test_truncated_4byte_at_end()
{
// Name with valid emoji 🌙 followed by a truncated 4-byte sequence + ASCII
char buf[32] = "Lunar Tower \xF0\x9F\x8C\x99\xF0\x9F\x97"
"4";
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
// The 🌙 should be preserved; F0 9F 97 is an incomplete 4-byte sequence,
// '4' (0x34) is not a valid continuation byte
TEST_ASSERT_EQUAL_STRING("Lunar Tower \xF0\x9F\x8C\x99???4", buf);
}
void test_lone_lead_bytes_without_continuations()
{
// Mixed ASCII with stray multibyte lead bytes (E1, F3) lacking proper continuations
char buf[32] = "Mesht\xE1\xF3tic 37e2";
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
// E1 expects 2 continuation bytes, but F3 is not a continuation → E1 replaced
// F3 expects 3 continuation bytes, 't','i','c' are not continuations → F3 replaced
TEST_ASSERT_EQUAL_STRING("Mesht??tic 37e2", buf);
}
// --- Edge cases ---
void test_bare_continuation_byte()
{
// 0x80 alone is invalid (continuation byte with no lead)
char buf[8] = "\x80";
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("?", buf);
}
void test_overlong_2byte()
{
// C0 AF is an overlong encoding of U+002F '/'
char buf[8] = "\xC0\xAF";
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
// C0 is a 2-byte lead, AF is valid continuation, but codepoint 0x2F < 0x80 → overlong
// C0 replaced, AF (now bare continuation) also replaced
TEST_ASSERT_EQUAL_STRING("??", buf);
}
void test_surrogate_half()
{
// ED A0 80 encodes U+D800 (surrogate half — invalid in UTF-8)
char buf[8] = "\xED\xA0\x80";
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("???", buf);
}
void test_5byte_sequence_rejected()
{
// F8 80 80 80 80 — 5-byte sequence, not valid UTF-8
char buf[8] = "\xF8\x80\x80\x80\x80";
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
// F8 is invalid lead (>= 0xF8), each 0x80 is bare continuation
TEST_ASSERT_EQUAL_STRING("?????", buf);
}
void test_truncated_3byte_at_buffer_end()
{
// Buffer is exactly 4 bytes: E2 82 then forced null at [3]
char buf[4];
buf[0] = '\xE2';
buf[1] = '\x82';
buf[2] = '\0'; // String ends before the 3-byte sequence completes
buf[3] = '\0';
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("??", buf);
}
void test_null_termination_enforced()
{
// Fill buffer completely with no null terminator
char buf[5];
memset(buf, 'A', sizeof(buf));
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
// Should be null-terminated and content preserved (all ASCII)
TEST_ASSERT_EQUAL_STRING("AAAA", buf);
}
void test_null_buffer()
{
TEST_ASSERT_FALSE(sanitizeUtf8(nullptr, 10));
}
void test_zero_size()
{
char buf[4] = "Hi";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, 0));
// Buffer should be untouched
TEST_ASSERT_EQUAL_STRING("Hi", buf);
}
void test_valid_max_codepoint()
{
// U+10FFFF = F4 8F BF BF (maximum valid Unicode codepoint)
char buf[8] = "\xF4\x8F\xBF\xBF";
TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("\xF4\x8F\xBF\xBF", buf);
}
void test_above_max_codepoint()
{
// U+110000 = F4 90 80 80 (just above maximum valid Unicode)
char buf[8] = "\xF4\x90\x80\x80";
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
}
void setup()
{
UNITY_BEGIN();
// Valid UTF-8 passthrough
RUN_TEST(test_ascii_unchanged);
RUN_TEST(test_valid_2byte_unchanged);
RUN_TEST(test_valid_3byte_unchanged);
RUN_TEST(test_valid_4byte_emoji_unchanged);
RUN_TEST(test_valid_mixed_unchanged);
RUN_TEST(test_empty_string);
// Invalid sequences observed in the wild
RUN_TEST(test_truncated_4byte_at_end);
RUN_TEST(test_lone_lead_bytes_without_continuations);
// Edge cases
RUN_TEST(test_bare_continuation_byte);
RUN_TEST(test_overlong_2byte);
RUN_TEST(test_surrogate_half);
RUN_TEST(test_5byte_sequence_rejected);
RUN_TEST(test_truncated_3byte_at_buffer_end);
RUN_TEST(test_null_termination_enforced);
RUN_TEST(test_null_buffer);
RUN_TEST(test_zero_size);
RUN_TEST(test_valid_max_codepoint);
RUN_TEST(test_above_max_codepoint);
exit(UNITY_END());
}
void loop() {}
+4 -3
View File
@@ -35,9 +35,10 @@
// code)
#endif
// Leave undefined to disable our PMU IRQ handler. DO NOT ENABLE THIS because the pmuirq can cause sperious interrupts
// and waking from light sleep
// #define PMU_IRQ 35
// Voiding more warranties.
#define PMU_IRQ 35
#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen)
#define HAS_AXP192
#define GPS_UBLOX
#define GPS_RX_PIN 34
+2 -2
View File
@@ -120,7 +120,7 @@ build_flags =
-D TFT_OFFSET_Y=0
-D TFT_OFFSET_ROTATION=0
-D SCREEN_ROTATE
-D SCREEN_TRANSITION_FRAMERATE=5
-D SCREEN_TRANSITION_FRAMERATE=30
-D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness
-D HAS_TOUCHSCREEN=1
-D TOUCH_I2C_PORT=0
@@ -133,4 +133,4 @@ lib_deps = ${heltec_v4_base.lib_deps}
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.19
# renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master
https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip
https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip
@@ -20,5 +20,5 @@ build_flags =
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main
https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip
https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip
upload_speed = 921600
@@ -13,7 +13,7 @@ build_flags =
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main
https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip
https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip
# renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver
https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+1 -2
View File
@@ -47,10 +47,9 @@
#define TFT_OFFSET_Y 0
#define TFT_OFFSET_ROTATION 0
#define SCREEN_ROTATE
#define SCREEN_TRANSITION_FRAMERATE 5
#define SCREEN_TRANSITION_FRAMERATE 30
// Picomputer gets a white on black display
#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255)
#define INPUTBROKER_MATRIX_TYPE 1
@@ -36,7 +36,7 @@
#define TFT_OFFSET_ROTATION 0
#define TFT_BL 45
#define SCREEN_ROTATE
#define SCREEN_TRANSITION_FRAMERATE 5 // fps
#define SCREEN_TRANSITION_FRAMERATE 30 // fps
#define USE_TFTDISPLAY 1
#define HAS_TOUCHSCREEN 1

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