Merge remote-tracking branch 'origin/develop' into pr10715-fix

# Conflicts:
#	src/graphics/Screen.cpp
#	src/modules/Telemetry/Sensor/PMSA003ISensor.h
#	src/modules/Telemetry/Sensor/SCD30Sensor.h
#	src/modules/Telemetry/Sensor/SCD4XSensor.h
#	src/modules/Telemetry/Sensor/SEN5XSensor.cpp
#	src/modules/Telemetry/Sensor/SEN5XSensor.h
#	src/modules/Telemetry/Sensor/SFA30Sensor.h
This commit is contained in:
Thomas Göttgens
2026-07-03 11:12:16 +02:00
535 changed files with 20722 additions and 20884 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
#define defaultBLEPin 123456
#if HAS_ETHERNET && defined(USE_ARDUINO_ETHERNET)
#include <Ethernet.h> // arduino-libraries/Ethernet supports W5500 auto-detect
#include <Ethernet.h> // arduino-libraries/Ethernet - supports W5500 auto-detect
#elif HAS_ETHERNET && defined(USE_CH390D)
#include <ESP32_CH390.h>
#elif HAS_ETHERNET && !defined(USE_WS5500)
+1 -1
View File
@@ -5,7 +5,7 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
bool usePreset)
{
// If use_preset is false, always return "Custom" callers such as RadioInterface and Channels
// If use_preset is false, always return "Custom" - callers such as RadioInterface and Channels
// rely on this being a stable literal for channel-name hashing and default-channel detection.
if (!usePreset) {
return "Custom";
+118 -31
View File
@@ -88,6 +88,9 @@ bool renameFile(const char *pathFrom, const char *pathTo)
#endif
}
#include <cstring>
#include <new>
#include <stdexcept>
#include <vector>
/**
@@ -119,6 +122,93 @@ bool fsFormat()
#endif
}
#ifdef FSCom
namespace
{
bool pathEndsWithDot(const char *path)
{
if (!path)
return false;
size_t length = strlen(path);
return length > 0 && path[length - 1] == '.';
}
bool copyFilePath(char *dest, size_t destSize, const char *path, bool *wasLimited)
{
if (!path || destSize == 0) {
if (wasLimited)
*wasLimited = true;
return false;
}
if (strlcpy(dest, path, destSize) >= destSize) {
if (wasLimited)
*wasLimited = true;
return false;
}
return true;
}
void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vector<meshtastic_FileInfo> &filenames,
bool *wasLimited)
{
if (!dirname)
return;
File root = FSCom.open(dirname, FILE_O_READ);
if (!root)
return;
if (!root.isDirectory()) {
root.close();
return;
}
File file = root.openNextFile();
// file.name()[0] check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
while (file && file.name()[0]) {
if (filenames.size() >= maxCount) {
if (wasLimited)
*wasLimited = true;
file.close();
break;
}
const char *fileName = file.name();
if (file.isDirectory() && !pathEndsWithDot(fileName)) {
char pathBuffer[sizeof(((meshtastic_FileInfo *)nullptr)->file_name)] = {};
#ifdef ARCH_ESP32
const char *subDirPath = file.path();
#else
const char *subDirPath = fileName;
#endif
bool hasSubDirPath = copyFilePath(pathBuffer, sizeof(pathBuffer), subDirPath, wasLimited);
file.close();
if (levels && hasSubDirPath) {
collectFiles(pathBuffer, levels - 1, maxCount, filenames, wasLimited);
} else if (wasLimited) {
*wasLimited = true;
}
} else {
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
#ifdef ARCH_ESP32
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.path(), wasLimited);
#else
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.name(), wasLimited);
#endif
if (hasFilePath && !pathEndsWithDot(fileInfo.file_name)) {
filenames.push_back(fileInfo);
}
file.close();
}
file = root.openNextFile();
}
root.close();
}
} // namespace
#endif
/**
* @brief Get the list of files in a directory.
*
@@ -127,43 +217,40 @@ bool fsFormat()
*
* @param dirname The name of the directory.
* @param levels The number of levels of subdirectories to list.
* @return A vector of strings containing the full path of each file in the directory.
* @param maxCount The maximum number of files to collect before truncating the walk.
* @param wasLimited Optional out-param, set to true if the listing was truncated (by maxCount or low memory).
* @return A vector of meshtastic_FileInfo for each file in the directory.
*/
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount, bool *wasLimited)
{
std::vector<meshtastic_FileInfo> filenames = {};
if (wasLimited)
*wasLimited = false;
#ifdef FSCom
File root = FSCom.open(dirname, FILE_O_READ);
if (!root)
return filenames;
if (!root.isDirectory())
return filenames;
File file = root.openNextFile();
while (file) {
#ifdef ARCH_ESP32
const char *filepath = file.path();
#else
const char *filepath = file.name();
#endif
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
if (levels) {
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
file.close();
}
} else {
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
if (!String(fileInfo.file_name).endsWith(".")) {
filenames.push_back(fileInfo);
}
file.close();
#if defined(__cpp_exceptions) || defined(__EXCEPTIONS)
size_t reservedCount = maxCount;
while (reservedCount > 0) {
try {
filenames.reserve(reservedCount);
break;
} catch (const std::bad_alloc &) {
reservedCount /= 2;
} catch (const std::length_error &) {
reservedCount /= 2;
}
file = root.openNextFile();
}
root.close();
if (reservedCount == 0) {
if (wasLimited)
*wasLimited = true;
return filenames;
}
if (reservedCount < maxCount) {
if (wasLimited)
*wasLimited = true;
maxCount = reservedCount;
}
#endif
collectFiles(dirname, levels, maxCount, filenames, wasLimited);
#endif
return filenames;
}
+2 -2
View File
@@ -49,7 +49,7 @@ using namespace Adafruit_LittleFS_Namespace;
#endif
#if defined(ARCH_NRF54L15)
// nRF54L15 Zephyr LittleFS on 36 KB storage_partition (internal RRAM)
// nRF54L15 - Zephyr LittleFS on 36 KB storage_partition (internal RRAM)
#include "InternalFileSystem.h"
#define FSCom InternalFS
#define FSBegin() FSCom.begin()
@@ -61,7 +61,7 @@ void fsListFiles();
bool copyFile(const char *from, const char *to);
bool renameFile(const char *pathFrom, const char *pathTo);
bool fsFormat();
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr);
void listDir(const char *dirname, uint8_t levels, bool del = false);
void rmDir(const char *dirname);
void setupSDCard();
+15 -1
View File
@@ -183,6 +183,10 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
sm.ackStatus = (packet.from == 0) ? AckStatus::NONE : AckStatus::ACKED;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
sm.xeddsaSigned = packet.xeddsa_signed;
#endif
addLiveMessage(sm);
#if ENABLE_MESSAGE_PERSISTENCE
@@ -230,6 +234,7 @@ struct __attribute__((packed)) StoredMessageRecord {
uint8_t isBootRelative;
uint8_t ackStatus; // static_cast<uint8_t>(AckStatus)
uint8_t type; // static_cast<uint8_t>(MessageType)
uint8_t xeddsaSigned; // 1 if packet carried a verified XEdDSA signature
uint16_t textLength; // message length
char text[MAX_MESSAGE_SIZE]; // store actual text here
};
@@ -245,6 +250,7 @@ static inline void writeMessageRecord(SafeFile &f, const StoredMessage &m)
rec.isBootRelative = m.isBootRelative;
rec.ackStatus = static_cast<uint8_t>(m.ackStatus);
rec.type = static_cast<uint8_t>(m.type);
rec.xeddsaSigned = m.xeddsaSigned ? 1 : 0;
rec.textLength = m.textLength;
// Copy the actual text into the record from RAM pool
@@ -269,6 +275,7 @@ static inline bool readMessageRecord(File &f, StoredMessage &m)
m.isBootRelative = rec.isBootRelative;
m.ackStatus = static_cast<AckStatus>(rec.ackStatus);
m.type = static_cast<MessageType>(rec.type);
m.xeddsaSigned = rec.xeddsaSigned != 0;
m.textLength = rec.textLength;
// 💡 Re-store text into pool and update offset
@@ -356,7 +363,14 @@ void MessageStore::clearAllMessages()
#ifdef FSCom
SafeFile f(filename.c_str(), false);
uint8_t count = 0;
f.write(&count, 1); // write "0 messages"
// SafeFile already does its own spiLock in its constructor and close().
// Avoid nesting spiLocks, as this will hang until watchdog reset!
{
concurrency::LockGuard guard(spiLock);
f.write(&count, 1); // write "0 messages"
}
f.close();
#endif
+5 -3
View File
@@ -31,7 +31,7 @@
#endif
#endif
// Internal alias used everywhere in code do NOT redefine elsewhere.
// Internal alias used everywhere in code - do NOT redefine elsewhere.
#define MAX_MESSAGES_SAVED MESSAGE_HISTORY_LIMIT
// Maximum text payload size per message in bytes.
@@ -68,14 +68,16 @@ struct StoredMessage {
bool isBootRelative; // true = millis()/1000 fallback; false = epoch/RTC absolute
AckStatus ackStatus; // Delivery status (only meaningful for our own sent messages)
// Text storage metadata rebuilt from flash at boot
// Text storage metadata - rebuilt from flash at boot
uint16_t textOffset; // Offset into global text pool (valid only after loadFromFlash())
uint16_t textLength; // Length of text in bytes
bool xeddsaSigned; // true if packet carried a verified XEdDSA signature
// Default constructor initializes all fields safely
StoredMessage()
: timestamp(0), sender(0), channelIndex(0), dest(0xffffffff), type(MessageType::BROADCAST), isBootRelative(false),
ackStatus(AckStatus::NONE), textOffset(0), textLength(0)
ackStatus(AckStatus::NONE), textOffset(0), textLength(0), xeddsaSigned(false)
{
}
};
+23
View File
@@ -24,6 +24,7 @@
#include "main.h"
#include "meshUtils.h"
#include "power/PowerHAL.h"
#include "power/SGM41562.h"
#include "sleep.h"
#ifdef ARCH_ESP32
// #include <driver/adc.h>
@@ -545,6 +546,10 @@ class AnalogBatteryLevel : public HasBatteryLevel
// lastly provide a fallback to indicate external power when fully charged.
virtual bool isVbusIn() override
{
#ifdef HAS_SGM41562
if (sgm41562 && sgm41562->refresh())
return sgm41562->isInputPowerGood();
#endif
#ifdef EXT_PWR_DETECT
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
@@ -561,6 +566,10 @@ class AnalogBatteryLevel : public HasBatteryLevel
/// we can't be smart enough to say 'full'?
virtual bool isCharging() override
{
#ifdef HAS_SGM41562
if (sgm41562 && sgm41562->refresh())
return sgm41562->isCharging();
#endif
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT) && !defined(HAS_PMU)
if (hasRAK()) {
return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse;
@@ -767,6 +776,12 @@ bool Power::analogInit()
*/
bool Power::setup()
{
#ifdef HAS_SGM41562
// Initialize the charger early so AnalogBatteryLevel can read charging
// state from it. The charger does not provide battery voltage / percent -
// those still come from the platform ADC via analogInit() below.
initSGM41562(SGM41562_WIRE);
#endif
bool found = false;
if (axpChipInit()) {
found = true;
@@ -823,6 +838,14 @@ void Power::reboot()
NVIC_SystemReset();
#elif defined(ARCH_RP2040)
rp2040.reboot();
#elif defined(ARCH_PORTDUINO_WASM)
// Browser/headless WASM node: no in-process restart. notifyReboot above
// already let modules persist; hand off to the host (reboot() ->
// location.reload() in a tab, or Module.onReboot() headless). Deliberately
// skip the ARCH_PORTDUINO SPI/Wire/Serial teardown below - it would kill the
// radio with no actual restart to follow, leaving a wedged node. Must come
// before the ARCH_PORTDUINO arm: the wasm build defines both macros.
::reboot();
#elif defined(ARCH_PORTDUINO)
deInitApiServer();
#ifdef __linux__
+19 -5
View File
@@ -58,6 +58,23 @@ static bool isPowered()
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
}
static bool isBluetoothEnabledForPowerFSM()
{
#if HAS_BLUETOOTH && !MESHTASTIC_EXCLUDE_BLUETOOTH
return config.bluetooth.enabled;
#else
return false;
#endif
}
static uint32_t getBluetoothWaitMs()
{
if (!isBluetoothEnabledForPowerFSM())
return 0;
return Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs);
}
#if defined(T5_S3_EPAPER_PRO)
static void t5BacklightOffForSleep()
{
@@ -220,7 +237,7 @@ static void serialEnter()
{
LOG_POWERFSM("State: serialEnter");
#ifndef ARCH_NRF52
// nRF52 runs BLE on SoftDevice independently of USB serial no need to disable it.
// nRF52 runs BLE on SoftDevice independently of USB serial - no need to disable it.
// (Same rationale as nbEnter() which already guards this with #ifdef ARCH_ESP32)
setBluetoothEnable(false);
#endif
@@ -429,10 +446,7 @@ void PowerFSM_setup()
// If ESP32 and using power-saving, timer mover from DARK to light-sleep
// Also serves purpose of the old DARK to DARK transition(?) See https://github.com/meshtastic/firmware/issues/3517
powerFSM.add_timed_transition(
&stateDARK, &stateLS,
Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs), NULL,
"Bluetooth timeout");
powerFSM.add_timed_transition(&stateDARK, &stateLS, getBluetoothWaitMs(), NULL, "Bluetooth timeout");
} else {
// If ESP32, but not using power-saving, check periodically if config has drifted out of stateDark
powerFSM.add_timed_transition(&stateDARK, &stateDARK,
+1 -4
View File
@@ -65,8 +65,6 @@ SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), con
Port.setRX(SERIAL2_RX);
#endif
Port.begin(SERIAL_BAUD);
#if defined(ARCH_NRF52) || defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(ARCH_RP2040) || \
defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6)
time_t timeout = millis();
while (!Port) {
if (Throttle::isWithinTimespanMs(timeout, FIVE_SECONDS_MS)) {
@@ -75,7 +73,6 @@ SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), con
break;
}
}
#endif
#if !ARCH_PORTDUINO
emitRebooted();
#endif
@@ -150,4 +147,4 @@ void SerialConsole::log_to_serial(const char *logLevel, const char *format, va_l
emitLogRecord(ll, thread ? thread->ThreadName.c_str() : "", format, arg);
} else
RedirectablePrint::log_to_serial(logLevel, format, arg);
}
}
+17 -10
View File
@@ -179,6 +179,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#endif
#endif
#ifdef USE_KCT8103L_PA_ONLY
#if defined(HELTEC_MESH_TOWER_V2)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 8, 7
#endif
#endif
#ifdef RAK13302
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 7, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8
@@ -574,15 +581,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#endif
// -----------------------------------------------------------------------------
// MESHTASTIC_LOCKDOWN runtime, client-toggleable hardening (nRF52 only)
// MESHTASTIC_LOCKDOWN - runtime, client-toggleable hardening (nRF52 only)
//
// Lockdown/protect support is opt-in at build time. Builds that need it pass
// -DMESHTASTIC_ENABLE_LOCKDOWN=1. When enabled on nRF52 (CC310 hardware
// crypto), whether it is ACTIVE is decided entirely at runtime by
// EncryptedStorage::isLockdownActive()
// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
// that has never been provisioned or that the operator disabled from the
// client app behaves exactly like stock firmware: plaintext storage, no
// that has never been provisioned - or that the operator disabled from the
// client app - behaves exactly like stock firmware: plaintext storage, no
// redaction, normal logging, normal display.
//
// The operator toggles lockdown from the client app:
@@ -590,7 +597,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// firmware generates a DEK, encrypts the stored config, and
// authorizes the connection.
// on -> off : AdminMessage.lockdown_auth { disable=true } with the
// passphrase decrypts storage back to plaintext and removes
// passphrase - decrypts storage back to plaintext and removes
// the DEK / token / monotonic-counter / backoff files, then
// reboots into normal mode. APPROTECT is the one thing that
// does NOT revert (see below).
@@ -600,20 +607,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// that genuinely cannot afford the ~tens-of-KB of crypto + access-control code
// may also opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
//
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL per-connection auth + redaction,
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL - per-connection auth + redaction,
// gated at runtime on isLockdownActive()
// MESHTASTIC_ENCRYPTED_STORAGE AES-128-CTR + HMAC-SHA256 at-rest
// MESHTASTIC_ENABLE_APPROTECT UICR APPROTECT capability. The actual
// MESHTASTIC_ENCRYPTED_STORAGE - AES-128-CTR + HMAC-SHA256 at-rest
// MESHTASTIC_ENABLE_APPROTECT - UICR APPROTECT capability. The actual
// one-way burn happens at runtime, only
// once provisioned, only on non-vulnerable
// silicon, and is STICKY: disabling
// lockdown does NOT (cannot) reverse it.
//
// DEBUG_MUTE is intentionally NOT coupled to lockdown a capable-but-off
// DEBUG_MUTE is intentionally NOT coupled to lockdown - a capable-but-off
// device must log normally. Define DEBUG_MUTE separately for a silent build.
//
// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
// even when provisioned for development so dev boards never lose SWD.
// even when provisioned - for development so dev boards never lose SWD.
// -----------------------------------------------------------------------------
#if defined(ARCH_NRF52)
#ifndef MESHTASTIC_ENABLE_LOCKDOWN
@@ -651,7 +658,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// Override at build time. Suggested:
// carry device: 3600 (1h sessions, periodic re-auth from phone)
// tower / infra node: 0 (default relies on token TTLs only)
// tower / infra node: 0 (default - relies on token TTLs only)
//
// A future LockdownAuth.max_session_seconds proto field will let the
// client set this per-token; until that lands the build-time value is
+5 -5
View File
@@ -37,15 +37,15 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
return firstOfOrNONE(11, types);
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
return firstOfOrNONE(12, types);
}
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
{
ScanI2C::DeviceType types[] = {MMC5983MA};
return firstOfOrNONE(1, types);
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
return firstOfOrNONE(2, types);
}
ScanI2C::FoundDevice ScanI2C::firstAQI() const
+3
View File
@@ -99,6 +99,9 @@ class ScanI2C
CW2015,
SCD30,
ADS1115,
IIS2MDCTR,
ISM330DHCX,
SPA06,
} DeviceType;
// typedef uint8_t DeviceAddress;
+75 -3
View File
@@ -11,6 +11,25 @@
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
#include "meshUtils.h" // vformat
#endif
#if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52))
#include "platform/nrf52/Nrf52Twim.h"
#include <QMA6100P.h>
namespace
{
bool probeQMA6100P(uint8_t address)
{
uint8_t chipID = 0;
Nrf52Twim::restoreBus();
const bool readOk = Nrf52Twim::readRegister(address, SFE_QMA6100P_CHIP_ID, chipID);
const bool found = readOk && chipID == QMA6100P_CHIP_ID;
Nrf52Twim::restoreBus();
return found;
}
} // namespace
#endif
bool in_array(uint8_t *array, int size, uint8_t lookfor)
@@ -244,11 +263,36 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
// 0x7C-0x7F Reserved for future purposes
for (addr.address = 8; addr.address < 120; addr.address++) {
#if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52))
bool nrf52QmaFound = false;
#endif
if (asize != 0) {
if (!in_array(address, asize, (uint8_t)addr.address))
continue;
LOG_DEBUG("Scan address 0x%x", (uint8_t)addr.address);
}
// For QMA6100P candidates on nRF52, use bounded I2C probing; otherwise use normal Wire
#if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52))
if (addr.address == QMA6100P_ADDRESS_LOW || addr.address == QMA6100P_ADDRESS_HIGH) {
nrf52QmaFound = probeQMA6100P(addr.address);
err = nrf52QmaFound ? 0 : 2;
} else {
i2cBus->beginTransmission(addr.address);
#ifdef ARCH_PORTDUINO
err = 2;
if ((addr.address >= 0x30 && addr.address <= 0x37) || (addr.address >= 0x50 && addr.address <= 0x5F)) {
if (i2cBus->read() != -1)
err = 0;
} else {
err = i2cBus->writeQuick((uint8_t)0);
}
if (err != 0)
err = 2;
#else
err = i2cBus->endTransmission();
#endif
}
#else
i2cBus->beginTransmission(addr.address);
#ifdef ARCH_PORTDUINO
err = 2;
@@ -262,6 +306,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
err = 2;
#else
err = i2cBus->endTransmission();
#endif
#endif
type = NONE;
if (err == 0) {
@@ -363,8 +408,12 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
logFoundDevice("DPS310", (uint8_t)addr.address);
type = DPS310;
break;
case 0x11:
logFoundDevice("SPA06-003", (uint8_t)addr.address);
type = SPA06;
break;
}
if (type == DPS310) {
if (type == DPS310 || type == SPA06) {
break;
}
default:
@@ -551,6 +600,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
if (registerValue == 0x6A) {
type = LSM6DS3;
logFoundDevice("LSM6DS3", (uint8_t)addr.address);
} else if (registerValue == 0x6B) {
type = ISM330DHCX;
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
} else {
type = QMI8658;
logFoundDevice("QMI8658", (uint8_t)addr.address);
@@ -558,7 +610,17 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
SCAN_SIMPLE_CASE(QMC5883L_ADDR, QMC5883L, "QMC5883L", (uint8_t)addr.address)
SCAN_SIMPLE_CASE(HMC5883L_ADDR, HMC5883L, "HMC5883L", (uint8_t)addr.address)
case HMC5883L_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x4FU), 1); // get ID
if (registerValue == 0x40) {
type = IIS2MDCTR;
logFoundDevice("IIS2MDCTR", (uint8_t)addr.address);
break;
} else {
type = HMC5883L;
logFoundDevice("HMC5883L", (uint8_t)addr.address);
break;
}
#ifdef HAS_QMA6100P
SCAN_SIMPLE_CASE(QMA6100P_ADDR, QMA6100P, "QMA6100P", (uint8_t)addr.address)
#else
@@ -704,7 +766,17 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
}
break;
}
SCAN_SIMPLE_CASE(BMM150_ADDR, BMM150, "BMM150", (uint8_t)addr.address);
case BMM150_ADDR:
#if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52))
if (nrf52QmaFound) {
logFoundDevice("QMA6100P", (uint8_t)addr.address);
type = QMA6100P;
break;
}
#endif
logFoundDevice("BMM150", (uint8_t)addr.address);
type = BMM150;
break;
#ifdef HAS_TPS65233
SCAN_SIMPLE_CASE(TPS65233_ADDR, TPS65233, "TPS65233", (uint8_t)addr.address);
#endif
+23 -5
View File
@@ -25,6 +25,7 @@
#include "ubx.h"
#ifdef ARCH_PORTDUINO
#include "GpsdSerial.h"
#include "PortduinoGlue.h"
#include "meshUtils.h"
#include <algorithm>
@@ -97,8 +98,9 @@ struct GPSProbeCacheRecord {
bool isValidGnssModel(uint8_t model)
{
// Keep persisted values bounded to known enum range.
return model <= static_cast<uint8_t>(GNSS_MODEL_CM121);
// Only real chip identifiers belong in the probe cache.
// GNSS_MODEL_UNKNOWN and GNSS_MODEL_GENERIC_NMEA are runtime-only values.
return model != static_cast<uint8_t>(GNSS_MODEL_UNKNOWN) && model < static_cast<uint8_t>(GNSS_MODEL_GENERIC_NMEA);
}
bool isValidProbeBaud(uint32_t baud)
@@ -1160,7 +1162,10 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
switch (newState) {
case GPS_ACTIVE:
case GPS_IDLE:
if (oldState == GPS_ACTIVE || oldState == GPS_IDLE) // If hardware already awake, no changes needed
if (oldState == GPS_ACTIVE)
break;
gotTime = false;
if (oldState == GPS_IDLE) // If hardware already awake, no changes needed
break;
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
clearBuffer();
@@ -1484,8 +1489,7 @@ int32_t GPS::runOnce()
// if gps_update_interval is <=10s, GPS never goes off, so we treat that differently
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
// 1. Got a time for the first time
bool gotTime = (getRTCQuality() >= RTCQualityGPS);
// 1. Got a time for the first time this cycle
if (!gotTime && lookForTime()) { // Note: we count on this && short-circuiting and not resetting the RTC time
gotTime = true;
}
@@ -1899,6 +1903,10 @@ std::unique_ptr<GPS> GPS::createGps()
// They are not used for any hardware access.
_rx_gpio = 1;
_tx_gpio = 1;
if (!portduino_config.gpsd_host.empty()) {
gpsdSerial.setAddress(portduino_config.gpsd_host, portduino_config.gpsd_port);
_serial_gps = &gpsdSerial;
}
} else
return nullptr;
#endif
@@ -1908,6 +1916,11 @@ std::unique_ptr<GPS> GPS::createGps()
auto new_gps = std::unique_ptr<GPS>(new GPS());
new_gps->rx_gpio = _rx_gpio;
new_gps->tx_gpio = _tx_gpio;
#ifdef ARCH_PORTDUINO
// Skip chip-specific probing for gpsd - it's a generic NMEA stream.
if (!portduino_config.gpsd_host.empty())
new_gps->gnssModel = GNSS_MODEL_GENERIC_NMEA;
#endif
GpioVirtPin *virtPin = new GpioVirtPin();
new_gps->enablePin = virtPin; // Always at least populate a virtual pin
@@ -2255,6 +2268,11 @@ int32_t GPS::disable()
return INT32_MAX;
}
bool GPS::isEnabled()
{
return enabled;
}
void GPS::toggleGpsMode()
{
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
+6 -1
View File
@@ -42,7 +42,8 @@ typedef enum {
GNSS_MODEL_AG3335,
GNSS_MODEL_AG3352,
GNSS_MODEL_LS20031,
GNSS_MODEL_CM121
GNSS_MODEL_CM121,
GNSS_MODEL_GENERIC_NMEA // generic NMEA source (e.g. gpsd); skips chip-specific probe and init
} GnssModel_t;
typedef enum {
@@ -98,6 +99,9 @@ class GPS : private concurrency::OSThread
// Disable the thread
int32_t disable() override;
// Returns if the thread is enabled
bool isEnabled();
// toggle between enabled/disabled
void toggleGpsMode();
@@ -174,6 +178,7 @@ class GPS : private concurrency::OSThread
uint32_t lastChecksumFailCount = 0;
uint8_t currentStep = 0;
int32_t currentDelay = 2000;
bool gotTime = false;
#ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS
// (20210908) TinyGps++ can only read the GPGSA "FIX TYPE" field
+1 -1
View File
@@ -20,7 +20,7 @@ void GPSUpdateScheduling::informGotLock()
// Search finished without obtaining a fix. We still need to mark the end time so
// the next sleep is timed correctly, but we must not feed the timeout duration
// into predictedMsToGetLock doing so poisons msUntilNextSearch() and causes
// into predictedMsToGetLock - doing so poisons msUntilNextSearch() and causes
// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles.
void GPSUpdateScheduling::informSearchFailed()
{
+40 -15
View File
@@ -1,4 +1,14 @@
#include "GeoCoord.h"
#include <cmath>
// Narrow a UTM meter value to its unsigned field, clamping non-finite/negative/oversized inputs: an
// extreme (crafted) lat/lon can drive these out of range, and an overflowing double->unsigned cast is UB.
static uint32_t clampMeters(double m)
{
if (!std::isfinite(m) || m < 0.0)
return 0;
return m > 4.0e9 ? 4000000000u : (uint32_t)m;
}
GeoCoord::GeoCoord()
{
@@ -124,8 +134,13 @@ void GeoCoord::latLongToUTM(const double lat, const double lon, UTM &utm)
{
const std::string latBands = "CDEFGHJKLMNPQRSTUVWXX";
utm.zone = int((lon + 180) / 6 + 1);
utm.band = latBands[int(lat / 8 + 10)];
// A received Position carries raw int32 latitude_i/longitude_i with no range validation, so lat/lon
// here can be far outside real geographic bounds. Clamp the derived UTM zone (valid 1..60) and the
// latitude-band index so the lookups below cannot read out of bounds (GeoCoord.cpp:128 stack over/
// under-read on e.g. latitude_i = INT32_MAX/INT32_MIN).
utm.zone = std::min(std::max(int((lon + 180) / 6 + 1), 1), 60);
int bandIdx = std::min(std::max(int(lat / 8 + 10), 0), int(latBands.length()) - 1);
utm.band = latBands[bandIdx];
double a = 6378137; // WGS84 - equatorial radius
double k0 = 0.9996; // UTM point scale on the central meridian
double eccSquared = 0.00669438; // eccentricity squared
@@ -160,17 +175,22 @@ void GeoCoord::latLongToUTM(const double lat, const double lon, UTM &utm)
sin(2 * latRad) +
(15 * eccSquared * eccSquared / 256 + 45 * eccSquared * eccSquared * eccSquared / 1024) * sin(4 * latRad) -
(35 * eccSquared * eccSquared * eccSquared / 3072) * sin(6 * latRad));
utm.easting = (double)(k0 * N *
(A + (1 - T + C) * pow(A, 3) / 6 +
(5 - 18 * T + T * T + 72 * C - 58 * eccPrimeSquared) * A * A * A * A * A / 120) +
500000.0);
utm.northing =
(double)(k0 * (M + N * tan(latRad) *
(A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24 +
(61 - 58 * T + T * T + 600 * C - 330 * eccPrimeSquared) * A * A * A * A * A * A / 720)));
double eastingMeters =
k0 * N *
(A + (1 - T + C) * pow(A, 3) / 6 + (5 - 18 * T + T * T + 72 * C - 58 * eccPrimeSquared) * A * A * A * A * A / 120) +
500000.0;
double northingMeters =
k0 * (M + N * tan(latRad) *
(A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24 +
(61 - 58 * T + T * T + 600 * C - 330 * eccPrimeSquared) * A * A * A * A * A * A / 720));
if (lat < 0)
utm.northing += 10000000.0; // 10000000 meter offset for southern hemisphere
northingMeters += 10000000.0; // 10000000 meter offset for southern hemisphere
// Clamp before narrowing to the unsigned UTM fields (see clampMeters): extreme lat/lon can drive
// these negative or past UINT32, and the raw double->unsigned cast would be UB.
utm.easting = clampMeters(eastingMeters);
utm.northing = clampMeters(northingMeters);
}
// Converts lat long coordinates to an MGRS.
@@ -182,10 +202,15 @@ void GeoCoord::latLongToMGRS(const double lat, const double lon, MGRS &mgrs)
latLongToUTM(lat, lon, utm);
mgrs.zone = utm.zone;
mgrs.band = utm.band;
double col = floor(utm.easting / 100000);
mgrs.east100k = e100kLetters[(mgrs.zone - 1) % 3][col - 1];
double row = (int32_t)floor(utm.northing / 100000.0) % 20;
mgrs.north100k = n100kLetters[(mgrs.zone - 1) % 2][row];
// utm.zone is clamped to 1..60 above, but guard every index defensively: the column/row derived
// from easting/northing can fall outside the 100km-grid letter tables when lat/lon are extreme.
int zoneIdx3 = ((mgrs.zone - 1) % 3 + 3) % 3;
int zoneIdx2 = ((mgrs.zone - 1) % 2 + 2) % 2;
int colIdx = std::min(std::max(int(floor(utm.easting / 100000)) - 1, 0), int(e100kLetters[zoneIdx3].length()) - 1);
mgrs.east100k = e100kLetters[zoneIdx3][colIdx];
int rowIdx = ((int32_t)floor(utm.northing / 100000.0) % 20 + 20) % 20;
rowIdx = std::min(std::max(rowIdx, 0), int(n100kLetters[zoneIdx2].length()) - 1);
mgrs.north100k = n100kLetters[zoneIdx2][rowIdx];
mgrs.easting = (int32_t)utm.easting % 100000;
mgrs.northing = (int32_t)utm.northing % 100000;
}
+89 -14
View File
@@ -31,13 +31,63 @@ static uint32_t
timeStartMsec; // Once we have a GPS lock, this is where we hold the initial msec clock that corresponds to that time
static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only updated once on initial lock
#ifdef PIO_UNIT_TESTING
// Test seam: unit tests can inject a fake system clock (e.g. the uptime seconds that
// gettimeofday() returns on boards without a real RTC, like RP2040) and force readFromRTC()
// down the no-hardware-RTC fallback even when a hardware-RTC branch is compiled in.
static bool hasMockSystemTime = false;
static bool forceSystemTimeFallback = false;
static struct timeval mockSystemTime = {};
#endif
// Reads the platform system clock (or the injected mock during unit tests). Used only by the
// no-hardware-RTC fallback below, so it may be unused on builds with a hardware RTC.
[[maybe_unused]] static bool readSystemTime(struct timeval *tv)
{
#ifdef PIO_UNIT_TESTING
if (hasMockSystemTime) {
*tv = mockSystemTime;
return true;
}
#endif
return gettimeofday(tv, NULL) == 0;
}
// Seeds the clock from the system time on boards without a hardware RTC. gettimeofday() can
// return uptime rather than wall-clock time there (e.g. RP2040), so only adopt it when we have
// nothing better yet -- never clobber a higher-quality GPS/NTP/phone source (issue #9828).
[[maybe_unused]] static RTCSetResult readFromSystemTimeFallback()
{
struct timeval tv;
if (readSystemTime(&tv)) {
uint32_t now = millis();
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
if (currentQuality == RTCQualityNone) {
LOG_DEBUG("Seed time from system clock: %lu", (unsigned long)printableEpoch);
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
} else {
LOG_DEBUG("Ignore system clock fallback (%lu); current RTC quality is %s", (unsigned long)printableEpoch,
RtcName(currentQuality));
}
return RTCSetResultSuccess;
}
return RTCSetResultNotSet;
}
/**
* Reads the current date and time from the RTC module and updates the system time.
* @return True if the RTC was successfully read and the system time was updated, false otherwise.
* Reads date/time from the RTC module (or system-time fallback) and seeds internal timekeeping.
* @return RTCSetResultSuccess if a time source was read successfully (even if an existing higher-quality time is retained).
*/
RTCSetResult readFromRTC()
{
struct timeval tv; /* btw settimeofday() is helpful here too*/
#ifdef PIO_UNIT_TESTING
if (forceSystemTimeFallback) {
return readFromSystemTimeFallback();
}
#endif
[[maybe_unused]] struct timeval tv; /* btw settimeofday() is helpful here too*/
#ifdef RV3028_RTC
if (rtc_found.address == RV3028_RTC) {
uint32_t now = millis();
@@ -162,14 +212,7 @@ RTCSetResult readFromRTC()
}
}
#else
if (!gettimeofday(&tv, NULL)) {
uint32_t now = millis();
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
LOG_DEBUG("Read RTC time as %ld", printableEpoch);
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
return RTCSetResultSuccess;
}
return readFromSystemTimeFallback();
#endif
return RTCSetResultNotSet;
}
@@ -219,8 +262,8 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
} else if (q == RTCQualityGPS) {
shouldSet = true;
LOG_DEBUG("Reapply GPS time: %ld secs", printableEpoch);
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (12 * 60 * 60 * 1000UL))) {
// Every 12 hrs we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (30 * 60 * 1000UL))) {
// Every 30 minutes we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
shouldSet = true;
LOG_DEBUG("Reapply external time to correct clock drift %ld secs", printableEpoch);
} else {
@@ -292,7 +335,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
LOG_WARN("Failed to set time for RX8130CE");
}
}
#elif defined(ARCH_ESP32)
#elif defined(ARCH_ESP32) || defined(ARCH_RP2040)
settimeofday(tv, NULL);
#endif
@@ -423,6 +466,38 @@ void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot)
lastSetFromPhoneNtpOrGps = 0;
lastTimeValidationWarning = 0;
}
void clearRTCSystemTimeForTests()
{
hasMockSystemTime = false;
mockSystemTime = {};
}
void setRTCSystemTimeForTests(const struct timeval *tv)
{
if (tv == NULL) {
clearRTCSystemTimeForTests();
return;
}
mockSystemTime = *tv;
hasMockSystemTime = true;
}
void setReadFromRTCUseSystemTimeForTests(bool enabled)
{
forceSystemTimeFallback = enabled;
}
void resetRTCStateForTests()
{
currentQuality = RTCQualityNone;
timeStartMsec = 0;
zeroOffsetSecs = 0;
lastSetFromPhoneNtpOrGps = 0;
lastTimeValidationWarning = 0;
setReadFromRTCUseSystemTimeForTests(false);
clearRTCSystemTimeForTests();
}
#endif
time_t gm_mktime(const struct tm *tm)
+4
View File
@@ -56,6 +56,10 @@ RTCSetResult readFromRTC();
#ifdef PIO_UNIT_TESTING
void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot);
void resetRTCStateForTests();
void setRTCSystemTimeForTests(const struct timeval *tv);
void clearRTCSystemTimeForTests();
void setReadFromRTCUseSystemTimeForTests(bool enabled);
#endif
time_t gm_mktime(const struct tm *tm);
+28 -29
View File
@@ -2,9 +2,9 @@
BaseUI
Developed and Maintained By:
- Ronald Garcia (HarukiToreda) Lead development and implementation.
- JasonP (Xaositek) Screen layout and icon design, UI improvements and testing.
- TonyG (Tropho) Project management, structural planning, and testing
- Ronald Garcia (HarukiToreda) - Lead development and implementation.
- JasonP (Xaositek) - Screen layout and icon design, UI improvements and testing.
- TonyG (Tropho) - Project management, structural planning, and testing
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -126,7 +126,7 @@ static inline void prepareFrameColorRegions()
// "LOCKED" plus battery so the operator can see the device is alive and
// charged without leaking any node/channel/message/position content.
// Draw the LOCKED frame into the host-side framebuffer. Does NOT commit
// to the panel the caller is responsible for calling display->display()
// to the panel - the caller is responsible for calling display->display()
// once it has composited any overlays on top. Committing here would cause
// visible flicker between "just LOCKED" and "LOCKED + banner overlay" when
// the pairing-PIN special-case in updateUiFrame paints the overlay after
@@ -166,8 +166,8 @@ static inline void updateUiFrame(OLEDDisplayUi *ui)
if (meshtastic_security::shouldRedactDisplay() && screen != nullptr) {
OLEDDisplay *display = screen->getDisplayDevice();
// Paint LOCKED into the framebuffer WITHOUT committing. We commit
// exactly once at the bottom after any overlay has been composed
// on top so the panel never visibly transitions from "just LOCKED"
// exactly once at the bottom - after any overlay has been composed
// on top - so the panel never visibly transitions from "just LOCKED"
// to "LOCKED + overlay" mid-frame. Committing twice per cycle was
// the source of the H13 flicker.
drawLockdownLockScreenIntoBuffer(display);
@@ -513,8 +513,21 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
static_cast<SSD1306Spi *>(dispdev)->setHorizontalOffset(32);
LOG_INFO("SSD1306 init success");
}
#elif defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7789_CS) || \
defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR)
#elif ARCH_PORTDUINO
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
if (portduino_config.displayPanel != no_screen) {
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
} else {
dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
isAUTOOled = true;
isI2cScreen = true;
}
}
#elif USE_TFTDISPLAY
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) && !defined(USE_EINK_PARALLELDISPLAY)
@@ -529,19 +542,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
dispdev = new ST7567Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
isI2cScreen = true;
#elif ARCH_PORTDUINO
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
if (portduino_config.displayPanel != no_screen) {
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
} else {
dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
isAUTOOled = true;
isI2cScreen = true;
}
}
#else
dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -668,7 +668,7 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
// 16-50 ms before the next ui->update() lands. Painting the
// LOCKED frame now ensures the only thing the operator (or
// someone over their shoulder) can see on wake is the redacted
// view. Gated on lockdown non-lockdown builds keep the
// view. Gated on lockdown - non-lockdown builds keep the
// previous frame as a UX cue that the display is just dimmed.
// dispdev is dereferenced unguarded throughout this file (incl.
// displayOff() just below), so no null check here.
@@ -798,7 +798,7 @@ void Screen::setup()
// M20: e-ink panels physically retain the last-rendered image without
// power, so a power-cycled lockdown handheld would keep showing
// operator-identifying content (position, messages, node info) until
// the firmware's first natural refresh which on e-ink can be seconds
// the firmware's first natural refresh - which on e-ink can be seconds
// into boot. Force a full refresh to the LOCKED frame here, immediately
// after the display is initialised and before any other rendering, so
// the persistent pixels are wiped to the redacted view before an
@@ -852,8 +852,7 @@ void Screen::setup()
dispdev->mirrorScreen();
#else
if (!config.display.flip_screen) {
#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \
defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR)
#if USE_TFTDISPLAY && !ARCH_PORTDUINO
static_cast<TFTDisplay *>(dispdev)->flipScreenVertically();
#elif defined(USE_ST7789)
static_cast<ST7789Spi *>(dispdev)->flipScreenVertically();
@@ -891,7 +890,7 @@ void Screen::setup()
touchScreenImpl1->init();
}
}
#elif HAS_TOUCHSCREEN && !defined(USE_EINK) && !HAS_CST226SE
#elif HAS_TOUCHSCREEN && !defined(USE_EINK) && !VARIANT_TOUCHSCREEN
touchScreenImpl1 =
new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast<TFTDisplay *>(dispdev)->getTouch);
touchScreenImpl1->init();
@@ -1041,7 +1040,7 @@ int32_t Screen::runOnce()
bool suppressRegionOnboard = false;
#ifdef MESHTASTIC_LOCKDOWN
// While lockdown is active and storage is still locked, config.lora.region
// is a deliberate UNSET placeholder the real region lives in encrypted
// is a deliberate UNSET placeholder - the real region lives in encrypted
// storage and is restored on unlock (see NodeDB's locked-boot path). Don't
// pop the region picker over the lock screen: it would trap input, and the
// operator can't set a region until they unlock anyway.
@@ -1479,7 +1478,7 @@ void Screen::setFrames(FrameFocus focus)
break;
case FOCUS_PRESERVE:
// No more adjustment force stay on same index
// No more adjustment - force stay on same index
if (previousFrameCount > fsi.frameCount) {
ui->switchToFrame(originalPosition - 1);
} else if (previousFrameCount < fsi.frameCount) {
@@ -1834,7 +1833,7 @@ void Screen::handleOnPress()
void Screen::logFrameChange(const char *reason, uint8_t targetIdx)
{
// Reverse-map an index to a stable name string keyed off FramePositions
// field names so the pytest harness can assert `name=nodelist_nodes`
// field names - so the pytest harness can assert `name=nodelist_nodes`
// without caring about how the positions were ordered this boot.
const auto &p = framesetInfo.positions;
const char *name = "unknown";
+3 -2
View File
@@ -711,9 +711,10 @@ class Screen : public concurrency::OSThread
// Test-only: emits one LOG_INFO line on every frame transition so the
// pytest harness can assert which frame is shown. Gated behind a macro
// so the chatty log doesn't ship in release builds. Enabled via
// build_testing_profile(enable_ui_log=True) in mcp-server/userprefs.py.
// build_testing_profile(enable_ui_log=True) in the meshtastic-mcp harness
// (https://github.com/meshtastic/meshtastic-mcp).
// Member function (not free) because FramesetInfo is a private nested
// type only methods of Screen can reach it.
// type - only methods of Screen can reach it.
void logFrameChange(const char *reason, uint8_t targetIdx);
#endif
+1 -4
View File
@@ -98,10 +98,7 @@
#define FONT_LARGE_LOCAL FONT_MEDIUM_LOCAL
#endif
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || \
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
#if (defined(USE_EINK) || defined(HAS_SPI_TFT)) && !defined(DISPLAY_FORCE_SMALL_FONTS)
// The screen is bigger so use bigger fonts
#define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19
#define FONT_MEDIUM FONT_LARGE_LOCAL // Height: 28
+14
View File
@@ -792,6 +792,20 @@ void clearTFTColorRegions()
colorRegionCount = 0;
}
// Per-row culling fast path (see TFTColorRegions.h / resolveTFTColorPixelRow()).
uint8_t tftColorRowRegions[MAX_TFT_COLOR_REGIONS];
uint8_t tftColorRowCount = 0;
void beginTFTColorRow(int16_t y)
{
tftColorRowCount = 0;
for (uint8_t i = 0; i < colorRegionCount; i++) {
const TFTColorRegion &r = colorRegions[i];
if (y >= r.y && y < r.y + r.height)
tftColorRowRegions[tftColorRowCount++] = i;
}
}
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--) {
+24 -3
View File
@@ -39,9 +39,7 @@ enum class TFTColorRole : uint8_t {
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)
#if HAS_TFT || defined(HAS_SPI_TFT)
#define GRAPHICS_TFT_COLORING_ENABLED 1
#else
#define GRAPHICS_TFT_COLORING_ENABLED 0
@@ -69,6 +67,29 @@ uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t default
// 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);
// -- Per-row fast path for the hot pixel loops in TFTDisplay::display() --------
// resolveTFTColorPixel() is O(regions) per pixel; for a full 800x480 repaint that
// dominates redraw time. Regions are vertically localized, so cull to the regions
// overlapping the current row once per row, then resolve each pixel against only
// those (inlined here, so no per-pixel cross-TU call).
extern uint8_t tftColorRowRegions[MAX_TFT_COLOR_REGIONS]; // indices into colorRegions[], ascending
extern uint8_t tftColorRowCount;
// Build tftColorRowRegions for row y. Call once per row before resolveTFTColorPixelRow().
void beginTFTColorRow(int16_t y);
// Resolve one pixel against the current row's active regions (set by beginTFTColorRow()).
// Highest-index region wins, matching resolveTFTColorPixel()'s precedence.
inline uint16_t resolveTFTColorPixelRow(int16_t x, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
{
for (int j = static_cast<int>(tftColorRowCount) - 1; j >= 0; j--) {
const TFTColorRegion &r = colorRegions[tftColorRowRegions[j]];
if (x >= r.x && x < r.x + r.width)
return isset ? r.onColorBe : r.offColorBe;
}
return isset ? defaultOnColor : defaultOffColor;
}
// -- Theme engine ------------------------------------------------------
// Each theme has four fields that work together:
//
+39 -9
View File
@@ -536,7 +536,7 @@ class LGFX : public lgfx::LGFX_Device
cfg.memory_width = 240;
cfg.memory_height = 320;
cfg.offset_x = 0;
cfg.offset_y = 0; // No vertical shift needed panel is top-aligned
cfg.offset_y = 0; // No vertical shift needed - panel is top-aligned
cfg.offset_rotation = 2; // Rotate 180° to correct upside-down layout
#else
cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC
@@ -1142,8 +1142,17 @@ class LGFX : public lgfx::LGFX_Device
static LGFX *tft = nullptr;
#endif
#elif defined(VARIANT_DISPLAY_DRIVER)
// Board-specific framebuffer backends (class LGFX) can livee in the
// variant files - variant_display.h (declaration) and
// variant_display.cpp (bodies) - so this shared
// file isn't inflated for a single board. It exposes the same surface TFTDisplay
// drives, so the generic `tft = new LGFX;` in connect() works.
#include "variant_display.h"
static LGFX *tft = nullptr;
#endif
#include "SPILock.h"
#include "TFTColorRegions.h"
#include "TFTDisplay.h"
@@ -1270,13 +1279,30 @@ void TFTDisplay::display(bool fromBlank)
y_byteMask = (1 << (y & 7));
uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth);
// Step 1: fill the whole row with the default colors. No per-pixel
// region scan, so background pixels (the bulk of the screen) are O(1).
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;
chunkRow[x] = isset ? colorTftWhite : colorTftBlack;
}
// Step 2: overprint each region overlapping this row, applied in
// ascending index order so the highest-index region wins (matches
// resolveTFTColorPixel precedence). Only region-covered pixels are
// re-touched, so total cost is ~screen + sum of region spans.
if (hasColorRegions) {
graphics::beginTFTColorRow(static_cast<int16_t>(y));
for (uint8_t k = 0; k < graphics::tftColorRowCount; k++) {
const graphics::TFTColorRegion &r = graphics::colorRegions[graphics::tftColorRowRegions[k]];
int32_t xs = r.x > 0 ? r.x : 0;
int32_t xe = r.x + r.width;
if (xe > (int32_t)displayWidth)
xe = (int32_t)displayWidth;
for (int32_t xx = xs; xx < xe; xx++) {
isset = (buffer[xx + y_byteIndex] & y_byteMask) != 0;
chunkRow[xx] = isset ? r.onColorBe : r.offColorBe;
}
}
}
}
@@ -1363,12 +1389,16 @@ void TFTDisplay::display(bool fromBlank)
}
// Step 3: Copy only the changed span into the pixel line buffer.
#if GRAPHICS_TFT_COLORING_ENABLED
if (hasColorRegions)
graphics::beginTFTColorRow(static_cast<int16_t>(y));
#endif
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);
linePixelBuffer[x] =
graphics::resolveTFTColorPixelRow(static_cast<int16_t>(x), isset, colorTftWhite, colorTftBlack);
} else {
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
}
+6 -15
View File
@@ -97,10 +97,7 @@ void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16
#ifdef ARCH_ESP32
if (!Throttle::isWithinTimespanMs(storeForwardModule->lastHeartbeat,
(storeForwardModule->heartbeatInterval * 1200))) { // no heartbeat, overlap a bit
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS) || \
defined(HACKADAY_COMMUNICATOR) || defined(USE_ST7796) || ARCH_PORTDUINO) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
#if (defined(USE_EINK) || defined(HAS_SPI_TFT) || ARCH_PORTDUINO) && !defined(DISPLAY_FORCE_SMALL_FONTS)
display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 12,
8, imgQuestionL1);
display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 12,
@@ -110,10 +107,7 @@ void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16
8, imgQuestion);
#endif
} else {
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS) || \
defined(HACKADAY_COMMUNICATOR) || defined(USE_ST7796)) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
#if (defined(USE_EINK) || defined(HAS_SPI_TFT)) && !defined(DISPLAY_FORCE_SMALL_FONTS)
display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 16,
8, imgSFL1);
display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 16,
@@ -126,10 +120,7 @@ void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16
#endif
} else {
// TODO: Raspberry Pi supports more than just the one screen size
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS) || \
defined(HACKADAY_COMMUNICATOR) || defined(USE_ST7796) || ARCH_PORTDUINO) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
#if (defined(USE_EINK) || defined(HAS_SPI_TFT) || ARCH_PORTDUINO) && !defined(DISPLAY_FORCE_SMALL_FONTS)
display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8,
imgInfoL1);
display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8,
@@ -491,9 +482,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
// Weighting for nonlinear segments
float milestone1 = 25;
float milestone2 = 40;
float weight1 = 0.45; // Weight for 025%
float weight2 = 0.35; // Weight for 2540%
float weight3 = 0.20; // Weight for 40100%
float weight1 = 0.45; // Weight for 0-25%
float weight2 = 0.35; // Weight for 25-40%
float weight3 = 0.20; // Weight for 40-100%
float totalWeight = weight1 + weight2 + weight3;
int seg1 = chutil_bar_max_fill * (weight1 / totalWeight);
+24 -7
View File
@@ -182,7 +182,7 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
// Reconcile the preset with the explicitly chosen region: a preset locked to another
// region would leave config.lora invalid until applyModemConfig() repairs it with
// error/critical-error side effects or, for the swappable EU trio, the clamp would
// error/critical-error side effects - or, for the swappable EU trio, the clamp would
// flip the region right back. The user picked the region, so the preset follows it.
const RegionInfo *newRegion = getRegion(region);
if (config.lora.use_preset && !newRegion->supportsPreset(config.lora.modem_preset)) {
@@ -214,6 +214,11 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG;
}
#if !MESHTASTIC_EXCLUDE_GPS
// Enable gps if it was previously disabled due to region not being set
if (gps != nullptr && !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED)
gps->enable();
#endif
service->reloadConfig(changes);
}
@@ -253,6 +258,9 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
{"ITU2_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M},
{"ITU3_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M},
{"ITU2_125CM (220-225)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM},
{"ITU1_70CM (430-440)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU1_70CM},
{"ITU2_70CM (420-450)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_70CM},
{"ITU3_70CM (430-450)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM},
};
@@ -277,7 +285,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
// Guard: without a reboot, reconfigure() applies the region directly, so reject
// regions this node can't use up front: unrecognized codes, licensed-only regions,
// and radio hardware mismatches (2.4 GHz vs sub-GHz) the same checks the admin
// and radio hardware mismatches (2.4 GHz vs sub-GHz) - the same checks the admin
// set-config path applies, but side-effect-free: ignoring a menu selection should
// not record a critical error or notify clients. getRadio() used to catch hardware
// mismatches post-reboot only.
@@ -463,7 +471,7 @@ static constexpr int MAX_PRESET_OPTIONS = 16;
static BannerOverlayOptions buildRegionPresetBanner()
{
// Static storage reused each call safe because the banner is shown immediately after.
// Static storage reused each call - safe because the banner is shown immediately after.
static const char *optionsArray[MAX_PRESET_OPTIONS];
static int optionsEnumArray[MAX_PRESET_OPTIONS];
static char presetLabelBuf[MAX_PRESET_OPTIONS][12]; // scratch space for name copies
@@ -1572,6 +1580,7 @@ void menuHandler::manageNodeMenu()
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
} else {
LOG_INFO("Adding node %08X to favorites", menuHandler::pickedNodeNum);
// set_favorite() already logs PROTECTED_CAP_WARN_FMT on a cap refusal; don't double-log here.
nodeDB->set_favorite(true, menuHandler::pickedNodeNum);
}
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
@@ -1615,15 +1624,23 @@ void menuHandler::manageNodeMenu()
return;
}
bool changed = false;
if (nodeInfoLiteIsIgnored(n)) {
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
} else {
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
changed = true;
} else if (nodeDB->setProtectedFlag(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
changed = true;
} else {
LOG_WARN(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", menuHandler::pickedNodeNum, MAX_NUM_NODES - 2);
}
// Only persist/notify when the ignore bit actually moved; a cap
// refusal changed nothing and shouldn't trigger a prefs save.
if (changed) {
nodeDB->notifyObservers(true);
nodeDB->saveToDisk();
}
nodeDB->notifyObservers(true);
nodeDB->saveToDisk();
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
return;
}
+13 -2
View File
@@ -615,12 +615,22 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
// Shrink Sender name if needed
int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " @...");
display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " *@...");
if (availWidth < 0)
availWidth = 0;
char truncatedSender[64];
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
// Determine signed-message prefix before building the header line, since it needs to go
// at the front of headerStr rather than appended after (strncat only appends at the end).
const char *signPrefix = "";
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
bool is_xeddsa_signed = m.xeddsaSigned;
if (is_xeddsa_signed) {
signPrefix = "*";
}
#endif
// Final header line
char headerStr[128];
if (mine) {
@@ -634,7 +644,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
}
} else {
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s @%s %s" : "%s @%s", timeBuf, truncatedSender, chanType);
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s %s@%s %s" : "%s %s@%s", timeBuf, signPrefix, truncatedSender,
chanType);
}
// Push header line
+1 -1
View File
@@ -261,7 +261,7 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
case notificationTypeEnum::text_banner:
case notificationTypeEnum::selection_picker:
case notificationTypeEnum::pairing_pin:
// pairing_pin is rendered the same as text_banner it's just a
// pairing_pin is rendered the same as text_banner - it's just a
// text banner. The split type exists only so the lockdown UI
// short-circuit in Screen.cpp can recognise the BLE pair-PIN
// banner as the one safe banner to composite over the LOCKED
+24 -5
View File
@@ -717,10 +717,7 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words);
}
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS) || \
defined(HACKADAY_COMMUNICATOR) || defined(USE_ST7796)) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
#if (defined(USE_EINK) || defined(HAS_SPI_TFT)) && !defined(DISPLAY_FORCE_SMALL_FONTS)
if (currentResolution == ScreenResolution::High) {
NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
@@ -791,12 +788,34 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
// Print node's long name (e.g. "Backpack Node")
if (username) {
int username_buffer = 0;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (nodeInfoLiteHasXeddsaSigned(node)) {
if (currentResolution == ScreenResolution::High) {
graphics::NodeListRenderer::drawScaledXBitmap16x16(x + 2, getTextPositions(display)[line] + 1,
xeddsa_shield_width, xeddsa_shield_height, xeddsa_shield,
display);
username_buffer = (xeddsa_shield_width * 2) + 4;
} else {
display->drawXbm(x, getTextPositions(display)[line] + 3, xeddsa_shield_width, xeddsa_shield_height,
xeddsa_shield);
username_buffer = xeddsa_shield_width + 2;
}
}
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
const int usernameWidth = UIRenderer::measureStringWithEmotes(display, username);
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (nodeInfoLiteHasXeddsaSigned(node)) {
setAndRegisterTFTColorRole(TFTColorRole::FavoriteNodeBGHighlight, TFTPalette::Yellow, TFTPalette::Black,
x + usernameWidth, getTextPositions(display)[line], username_buffer, FONT_HEIGHT_SMALL);
}
#endif
setAndRegisterTFTColorRole(TFTColorRole::FavoriteNodeBGHighlight, TFTPalette::Yellow, TFTPalette::Black, x,
getTextPositions(display)[line], usernameWidth, FONT_HEIGHT_SMALL);
#endif
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
UIRenderer::drawStringWithEmotes(display, x + username_buffer, getTextPositions(display)[line++], username,
FONT_HEIGHT_SMALL, 1, false);
}
#if !MESHTASTIC_EXCLUDE_STATUS && !MESHTASTIC_EXCLUDE_STATUSDB
+5 -4
View File
@@ -26,10 +26,7 @@ const uint8_t bluetoothConnectedIcon[36] PROGMEM = {0xfe, 0x01, 0xff, 0x03, 0x03
0xf3, 0x3f, 0x33, 0x30, 0x33, 0x33, 0x33, 0x33, 0x03, 0x33, 0xff, 0x33,
0xfe, 0x31, 0x00, 0x30, 0x30, 0x30, 0x30, 0x30, 0xf0, 0x3f, 0xe0, 0x1f};
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || \
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR) || ARCH_PORTDUINO) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
#if (defined(USE_EINK) || defined(HAS_SPI_TFT) || ARCH_PORTDUINO) && !defined(DISPLAY_FORCE_SMALL_FONTS)
const uint8_t imgQuestionL1[] PROGMEM = {0xff, 0x01, 0x01, 0x32, 0x7b, 0x49, 0x49, 0x6f, 0x26, 0x01, 0x01, 0xff};
const uint8_t imgQuestionL2[] PROGMEM = {0x0f, 0x08, 0x08, 0x08, 0x06, 0x0f, 0x0f, 0x06, 0x08, 0x08, 0x08, 0x0f};
const uint8_t imgInfoL1[] PROGMEM = {0xff, 0x01, 0x01, 0x01, 0x1e, 0x7f, 0x1e, 0x01, 0x01, 0x01, 0x01, 0xff};
@@ -294,6 +291,10 @@ const uint8_t digital_icon_clock[] PROGMEM = {0b00111100, 0b01000010, 0b10000101
const uint8_t analog_icon_clock[] PROGMEM = {0b11111111, 0b01000010, 0b00100100, 0b00011000,
0b00100100, 0b01000010, 0b01000010, 0b11111111};
#define xeddsa_shield_width 8
#define xeddsa_shield_height 8
const uint8_t xeddsa_shield[] PROGMEM = {0x7E, 0x8F, 0x8F, 0x8F, 0xF1, 0xF1, 0x72, 0x3C};
#define chirpy_width 38
#define chirpy_height 50
const uint8_t chirpy[] = {
+2 -2
View File
@@ -134,7 +134,7 @@ class SafeFastEPD : public FASTEPD
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
// Parallel display - SPI parameters are not used
(void)spi;
(void)pin_dc;
(void)pin_cs;
@@ -159,7 +159,7 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_
// 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"
#error "ED047TC1 driver: unsupported variant - define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2"
#endif
if (initRc != BBEP_SUCCESS) {
+4 -4
View File
@@ -10,7 +10,7 @@
Unlike the other NicheGraphics EInk drivers, this one drives a parallel e-paper
panel via the FastEPD library. SPI parameters passed to begin() are ignored.
The ED047TC1 panel has an inactive pixel border on all four edges (~48 physical
The ED047TC1 panel has an inactive pixel border on all four edges (~4-8 physical
pixels). DISPLAY_WIDTH / DISPLAY_HEIGHT expose a reduced "safe area" to InkHUD so
that content is never drawn into this dead zone. The update() method copies the
InkHUD frame buffer into the centre of the larger physical 960×540 buffer, using
@@ -18,9 +18,9 @@
V_OFFSET_TOP and V_OFFSET_BOTTOM (vertical, pixel rows) to position it.
Changing these constants shifts content inward from each physical edge:
H_OFFSET_BYTES = 2 → 16px left margin, 16px right margin (960 16 16 = 928)
H_OFFSET_BYTES = 2 → 16px left margin, 16px right margin (960 - 16 - 16 = 928)
V_OFFSET_TOP = 16 → 16px top margin
V_OFFSET_BOTTOM = 16 → 16px bottom margin (540 16 16 = 508)
V_OFFSET_BOTTOM = 16 → 16px bottom margin (540 - 16 - 16 = 508)
*/
@@ -74,7 +74,7 @@ class ED047TC1 : public EInk
public:
ED047TC1() : EInk(DISPLAY_WIDTH, DISPLAY_HEIGHT, supported) {}
// EInk interface SPI params are not used for this parallel display
// EInk interface - SPI params are not used for this parallel display
void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = 0xFF) override;
void update(uint8_t *imageData, UpdateTypes type) override;
+2 -3
View File
@@ -439,12 +439,11 @@ InkHUD::Applet::SignalStrength InkHUD::Applet::getSignalStrength(float snr, floa
return SIGNAL_NONE;
}
// Apply the standard "node id" formatting to a nodenum int: !0123abdc
// Format a node number as the standard user-facing node ID string: !xxxxxxxx
std::string InkHUD::Applet::hexifyNodeNum(NodeNum num)
{
// Not found in nodeDB, show a hex nodeid instead
char nodeIdHex[10];
sprintf(nodeIdHex, "!%0x", num); // Convert to the typical "fixed width hex with !" format
sprintf(nodeIdHex, "!%08x", num);
return std::string(nodeIdHex);
}
+2
View File
@@ -128,6 +128,8 @@ class Applet : public GFX
virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification
virtual class MapApplet *asMapApplet() { return nullptr; } // Returns non-null only for MapApplet and its subclasses
static uint16_t getHeaderHeight(); // How tall the "standard" applet header is
static AppletFont fontSmall, fontMedium, fontLarge; // The general purpose fonts, used by all applets
@@ -1,18 +1,367 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./MapApplet.h"
#include "./MapTile.h"
#include <math.h>
#include <string.h>
using namespace NicheGraphics;
bool InkHUD::MapApplet::s_zoomLocked = false;
int InkHUD::MapApplet::s_lockedZoom = -1;
int InkHUD::MapApplet::s_lastRenderedZoom = -1;
int InkHUD::MapApplet::s_autoFitZoom = -1;
// Observe GPS position updates so the map redraws whenever a new location arrives.
InkHUD::MapApplet::MapApplet()
{
if (gpsStatus)
gpsStatusObserver.observe(&gpsStatus->onNewStatus);
}
int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status)
{
if (status->getStatusType() != STATUS_TYPE_GPS)
return 0;
if (!isActive() || !gpsStatus->getHasLock())
return 0;
requestUpdate();
return 0;
}
// Zoom in one step from the current display zoom.
void InkHUD::MapApplet::zoomIn()
{
int baseZoom = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (baseZoom < 0)
return;
if (map_tile_count == 0) {
if (baseZoom < ZOOM_MAX_NO_TILES) {
s_lockedZoom = baseZoom + 1;
s_zoomLocked = true;
}
return;
}
// Jump to the next tile zoom strictly above current, not just +1
int next = -1;
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (z > baseZoom && (next < 0 || z < next))
next = z;
}
if (next < 0)
return;
s_lockedZoom = next;
s_zoomLocked = true;
}
void InkHUD::MapApplet::resetZoom()
{
s_zoomLocked = false;
s_lockedZoom = -1;
}
bool InkHUD::MapApplet::canZoomIn() const
{
if (s_lastRenderedZoom < 0)
return false;
int ref = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (map_tile_count == 0)
return ref < ZOOM_MAX_NO_TILES;
for (int i = 0; i < map_tile_count; i++) {
if (map_tile_zooms[i] > ref)
return true;
}
return false;
}
void InkHUD::MapApplet::zoomOut()
{
int baseZoom = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (baseZoom < 0) {
s_zoomLocked = false;
s_lockedZoom = -1;
return;
}
if (map_tile_count == 0) {
int floor = (s_autoFitZoom >= 0) ? s_autoFitZoom : baseZoom;
if (baseZoom > floor) {
s_lockedZoom = baseZoom - 1;
s_zoomLocked = true;
} else {
s_zoomLocked = false;
s_lockedZoom = -1;
}
return;
}
// Jump to the next tile zoom strictly below current, not just -1
int next = -1;
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (z < baseZoom && (next < 0 || z > next))
next = z;
}
if (next < 0) {
s_zoomLocked = false;
s_lockedZoom = -1;
return;
}
s_lockedZoom = next;
s_zoomLocked = true;
}
bool InkHUD::MapApplet::canZoomOut() const
{
if (s_lastRenderedZoom < 0)
return false;
int ref = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (map_tile_count == 0)
return s_autoFitZoom >= 0 ? ref > s_autoFitZoom : false;
for (int i = 0; i < map_tile_count; i++) {
if (map_tile_zooms[i] < ref)
return true;
}
return false;
}
// Raw LZ4 block decompressor. Returns bytes written, or -1 on error.
static int lz4_decompress(const uint8_t *src, int src_len, uint8_t *dst, int dst_cap)
{
const uint8_t *s = src;
const uint8_t *s_end = src + src_len;
uint8_t *d = dst;
const uint8_t *d_end = dst + dst_cap;
while (s < s_end) {
uint8_t token = *s++;
int lit_len = (token >> 4) & 0xF;
if (lit_len == 15) {
uint8_t x;
do {
x = *s++;
lit_len += x;
} while (x == 255 && s < s_end);
}
if (d + lit_len > d_end || s + lit_len > s_end)
return -1;
memcpy(d, s, lit_len);
d += lit_len;
s += lit_len;
if (s >= s_end)
break;
if (s + 2 > s_end)
return -1;
int offset = (int)s[0] | ((int)s[1] << 8);
s += 2;
if (offset == 0 || d - offset < dst)
return -1;
int mat_len = (token & 0xF) + 4;
if (mat_len == 4 + 15) {
uint8_t x;
do {
x = *s++;
mat_len += x;
} while (x == 255 && s < s_end);
}
if (d + mat_len > d_end)
return -1;
const uint8_t *m = d - offset;
for (int i = 0; i < mat_len; i++)
*d++ = m[i];
}
return (int)(d - dst);
}
// Tiles are 1 bit/pixel, column-major: [bx=0..31][y=0..255], 8 pixels per byte.
static uint8_t s_tileCacheBuffer[8192];
static const uint8_t *decodeSparseTile(int tileIndex)
{
int n = lz4_decompress(map_tile_data[tileIndex], map_tile_sizes[tileIndex], s_tileCacheBuffer, sizeof(s_tileCacheBuffer));
return n == sizeof(s_tileCacheBuffer) ? s_tileCacheBuffer : nullptr;
}
// Draw tiles centered on latCenter/lngCenter. Falls back to the nearest available zoom if
// no tiles exist at exactly zoom (upsamples), enabling smooth zoom steps.
void InkHUD::MapApplet::drawMapTileBackground(int zoom)
{
if (map_tile_count == 0 || metersToPx <= 0.0f)
return;
const float R = 6378137.0f;
const float latRad = latCenter * DEG_TO_RAD;
const float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << zoom))) * cosf(latRad);
const float worldPxPerScreenPx = 1.0f / (metersToPx * mpp);
// Find best tile zoom: highest available <= zoom, or lowest available if none below.
int tileZoom = -1;
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (z <= zoom && (tileZoom < 0 || z > tileZoom))
tileZoom = z;
}
if (tileZoom < 0) {
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (tileZoom < 0 || z < tileZoom)
tileZoom = z;
}
}
if (tileZoom < 0)
return;
// Convert screen-pixel movement into tileZoom coordinate space.
// When tileZoom < zoom, tile pixels are upsampled (each tile pixel covers >1 screen px).
const float tileWorldPx = worldPxPerScreenPx * ((float)(1 << tileZoom) / (float)(1 << zoom));
const float sinLat = sinf(latRad);
const float gpxX = ((lngCenter + 180.0f) / 360.0f) * (float)(1 << tileZoom) * 256.0f;
const float gpxY = (0.5f - logf((1.0f + sinLat) / (1.0f - sinLat)) / (4.0f * M_PI)) * (float)(1 << tileZoom) * 256.0f;
const float minWx = gpxX - width() * 0.5f * tileWorldPx;
const float maxWx = gpxX + width() * 0.5f * tileWorldPx;
const float minWy = gpxY - height() * 0.5f * tileWorldPx;
const float maxWy = gpxY + height() * 0.5f * tileWorldPx;
for (int i = 0; i < map_tile_count; i++) {
if (map_tile_zooms[i] != tileZoom)
continue;
const int tx = map_tile_tx[i];
const int ty = map_tile_ty[i];
const float tileMinWx = tx * 256.0f;
const float tileMaxWx = tileMinWx + 256.0f;
const float tileMinWy = ty * 256.0f;
const float tileMaxWy = tileMinWy + 256.0f;
if (tileMaxWx < minWx || tileMinWx > maxWx || tileMaxWy < minWy || tileMinWy > maxWy)
continue;
const uint8_t *tile = decodeSparseTile(i);
if (!tile)
continue;
const int sxStart = max(0, (int)floorf(((tileMinWx - gpxX) / tileWorldPx) + width() * 0.5f));
const int sxEnd = min(width() - 1, (int)ceilf(((tileMaxWx - gpxX) / tileWorldPx) + width() * 0.5f) - 1);
const int syStart = max(0, (int)floorf(((tileMinWy - gpxY) / tileWorldPx) + height() * 0.5f));
const int syEnd = min(height() - 1, (int)ceilf(((tileMaxWy - gpxY) / tileWorldPx) + height() * 0.5f) - 1);
for (int sy = syStart; sy <= syEnd; sy++) {
const float wy = gpxY + (sy - height() * 0.5f) * tileWorldPx;
const int py = (int)(wy - tileMinWy);
if (py < 0 || py > 255)
continue;
for (int sx = sxStart; sx <= sxEnd; sx++) {
const float wx = gpxX + (sx - width() * 0.5f) * tileWorldPx;
const int px = (int)(wx - tileMinWx);
if (px < 0 || px > 255)
continue;
if (!(tile[(px / 8) * 256 + py] & (1 << (px % 8))))
continue;
drawPixel(sx, sy, BLACK);
}
}
}
}
void InkHUD::MapApplet::onRender(bool full)
{
// Abort if no markers to render
if (!enoughMarkers()) {
// Map center is always the node centroid - tiles are background only.
getMapCenter(&latCenter, &lngCenter);
calculateAllMarkers();
// Show placeholder only if we have no position at all - no tiles, no own node
if (!enoughMarkers() && !centerIsOurNode) {
printAt(X(0.5), Y(0.5) - (getFont().lineHeight() / 2), "Node positions", CENTER, MIDDLE);
printAt(X(0.5), Y(0.5) + (getFont().lineHeight() / 2), "will appear here", CENTER, MIDDLE);
return;
}
// Determine the metersToPx needed to fit all nodes on screen.
getMapSize(&widthMeters, &heightMeters);
calculateMapScale(); // metersToPx = fit-all-nodes scale
const float metersToPxFit = metersToPx;
// Pick the highest zoom whose native scale fits all nodes (no downsampling, no dither noise).
{
const float R = 6378137.0f;
const float latRad = latCenter * DEG_TO_RAD;
// Collect unique zooms, sort descending (highest detail first)
int zooms[16] = {};
int nzooms = 0;
for (int i = 0; i < map_tile_count && nzooms < 16; i++) {
bool found = false;
for (int j = 0; j < nzooms; j++) {
if (zooms[j] == map_tile_zooms[i]) {
found = true;
break;
}
}
if (!found)
zooms[nzooms++] = map_tile_zooms[i];
}
for (int i = 0; i < nzooms - 1; i++) {
for (int j = i + 1; j < nzooms; j++) {
if (zooms[j] > zooms[i]) {
int t = zooms[i];
zooms[i] = zooms[j];
zooms[j] = t;
}
}
}
int chosenZoom = (nzooms > 0) ? zooms[nzooms - 1] : 13; // fallback: widest zoom
float chosenMetersToPx = metersToPxFit; // fallback: fit-scale (may downsample)
if (s_zoomLocked && s_lockedZoom >= 0) {
// Use locked zoom at native 1:1 scale - never zoom out for new nodes
chosenZoom = s_lockedZoom;
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad);
chosenMetersToPx = 1.0f / mpp;
} else if ((markers.empty() || metersToPxFit <= 0.0f) && nzooms > 0) {
// No spread to fit (own node only, or single remote node at map center). Use highest zoom at native scale.
chosenZoom = zooms[0];
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad);
chosenMetersToPx = 1.0f / mpp;
} else {
for (int zi = 0; zi < nzooms; zi++) {
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << zooms[zi]))) * cosf(latRad);
float nativeMetersToPx = 1.0f / mpp;
if (nativeMetersToPx <= metersToPxFit) {
// This zoom at native scale shows all nodes - use it (highest detail that fits)
chosenZoom = zooms[zi];
chosenMetersToPx = nativeMetersToPx;
break;
}
}
}
if (!s_zoomLocked)
s_autoFitZoom = chosenZoom;
metersToPx = chosenMetersToPx;
s_lastRenderedZoom = chosenZoom;
drawMapTileBackground(chosenZoom);
char zoomLabel[8];
snprintf(zoomLabel, sizeof(zoomLabel), "z%d", chosenZoom);
int16_t zoomLabelW = getTextWidth(zoomLabel);
int16_t zoomLabelH = getFont().lineHeight();
int16_t zoomLabelX = width() - zoomLabelW - 3;
int16_t zoomLabelY = 2;
fillRect(zoomLabelX - 2, zoomLabelY - 1, zoomLabelW + 4, zoomLabelH + 2, WHITE);
printAt(zoomLabelX, zoomLabelY, zoomLabel, LEFT, TOP);
}
// Helper: draw rounded rectangle centered at x,y
auto fillRoundedRect = [&](int16_t cx, int16_t cy, int16_t w, int16_t h, int16_t r, uint16_t color) {
int16_t x = cx - (w / 2);
@@ -30,16 +379,10 @@ void InkHUD::MapApplet::onRender(bool full)
fillCircle(x + w - r - 1, y + h - r - 1, r, color);
};
// Find center of map
getMapCenter(&latCenter, &lngCenter);
calculateAllMarkers();
getMapSize(&widthMeters, &heightMeters);
calculateMapScale();
// Draw all markers first
for (Marker m : markers) {
int16_t x = X(0.5) + (m.eastMeters * metersToPx);
int16_t y = Y(0.5) - (m.northMeters * metersToPx);
int16_t x = X(0.5) + (int16_t)(m.eastMeters * metersToPx);
int16_t y = Y(0.5) - (int16_t)(m.northMeters * metersToPx);
// Add white halo outline first
constexpr int outlinePad = 1;
@@ -57,10 +400,8 @@ void InkHUD::MapApplet::onRender(bool full)
setTextColor(WHITE);
// Draw actual marker on top
if (m.hasHopsAway && m.hopsAway > config.lora.hop_limit) {
if (m.hopsAway > config.lora.hop_limit) {
printAt(x + 1, y + 1, "X", CENTER, MIDDLE);
} else if (!m.hasHopsAway) {
printAt(x + 1, y + 1, "?", CENTER, MIDDLE);
} else {
char hopStr[4];
snprintf(hopStr, sizeof(hopStr), "%d", m.hopsAway);
@@ -73,6 +414,8 @@ void InkHUD::MapApplet::onRender(bool full)
}
// Dual map scale bars
if (metersToPx <= 0.0f)
return;
int16_t horizPx = width() * 0.25f;
int16_t vertPx = height() * 0.25f;
float horizMeters = horizPx / metersToPx;
@@ -136,11 +479,11 @@ void InkHUD::MapApplet::onRender(bool full)
printAt(vertBarX + (bottomLabelW / 2) + 1, bottomLabelY + (bottomLabelH / 2), vertBottomLabel, CENTER, MIDDLE);
// Draw our node LAST with full white fill + outline
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
meshtastic_PositionLite ourSelfPos;
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
Marker self = calculateMarker(ourSelfPos.latitude_i * 1e-7, ourSelfPos.longitude_i * 1e-7, false, 0);
if (centerIsOurNode) {
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
meshtastic_PositionLite ourSelfPos;
nodeDB->copyNodePosition(ourNode->num, ourSelfPos);
Marker self = calculateMarker(ourSelfPos.latitude_i * 1e-7, ourSelfPos.longitude_i * 1e-7, 0);
int16_t centerX = X(0.5) + (self.eastMeters * metersToPx);
int16_t centerY = Y(0.5) - (self.northMeters * metersToPx);
@@ -174,7 +517,9 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
*lat = ourSelfPos.latitude_i * 1e-7;
*lng = ourSelfPos.longitude_i * 1e-7;
centerIsOurNode = true;
} else {
centerIsOurNode = false;
// Find mean lat long coords
// ============================
// - assigning X, Y and Z values to position on Earth's surface in 3D space, relative to center of planet
@@ -225,6 +570,8 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
}
// All NodeDB processed, find mean values
if (positionCount == 0)
return;
xAvg /= positionCount;
yAvg /= positionCount;
zAvg /= positionCount;
@@ -278,18 +625,43 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
latCenter = *lat;
lngCenter = *lng;
// ----------------------------------------------
// This has given us either:
// - our actual position (preferred), or
// - a mean position (fallback if we had no fix)
//
// What we actually want is to place our center so that our outermost nodes
// end up on the border of our map. The only real use of our "center" is to give
// us a reference frame: which direction is east, and which is west.
//------------------------------------------------
// When zoom is locked, keep center exactly on own node / zero-hop centroid.
// Skip bounding-box shift so new distant nodes don't move the zoomed view.
if (s_zoomLocked) {
// Own node has no position - re-center on zero-hop centroid instead.
if (!centerIsOurNode) {
uint32_t count = 0;
float xAvg = 0, yAvg = 0, zAvg = 0;
for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
if (!nodeDB->hasValidPosition(node) || !shouldDrawNode(node))
continue;
if (!node->has_hops_away || node->hops_away != 0)
continue;
meshtastic_PositionLite pos;
if (!nodeDB->copyNodePosition(node->num, pos))
continue;
float latRad2 = pos.latitude_i * 1e-7 * DEG_TO_RAD;
float lngRad2 = pos.longitude_i * 1e-7 * DEG_TO_RAD;
xAvg += cosf(latRad2) * cosf(lngRad2);
yAvg += cosf(latRad2) * sinf(lngRad2);
zAvg += sinf(latRad2);
count++;
}
if (count > 0) {
xAvg /= count;
yAvg /= count;
zAvg /= count;
*lng = atan2f(yAvg, xAvg) * RAD_TO_DEG;
*lat = atan2f(zAvg, sqrtf(xAvg * xAvg + yAvg * yAvg)) * RAD_TO_DEG;
latCenter = *lat;
lngCenter = *lng;
}
}
return; // Do not shift center based on bounding box
}
// Find furthest nodes from our center
// ========================================
// Find furthest nodes from our center, shift center to midpoint of bounding box
float northernmost = latCenter;
float southernmost = latCenter;
float easternmost = lngCenter;
@@ -298,11 +670,8 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Skip if no position
if (!nodeDB->hasValidPosition(node))
continue;
// Skip if derived applet doesn't want to show this node on the map
if (!shouldDrawNode(node))
continue;
@@ -310,15 +679,14 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
if (!nodeDB->copyNodePosition(node->num, pos))
continue;
// Check for a new top or bottom latitude
float latNode = pos.latitude_i * 1e-7;
float lngNode = pos.longitude_i * 1e-7;
northernmost = max(northernmost, latNode);
southernmost = min(southernmost, latNode);
// Longitude is trickier
float lngNode = pos.longitude_i * 1e-7;
float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees traveled east from lngCenter to reach node
float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees traveled west from lngCenter to reach node
float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees east from center to node
float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees west from center to node
if (degEastward < degWestward)
easternmost = max(easternmost, lngCenter + degEastward);
else
@@ -356,7 +724,7 @@ void InkHUD::MapApplet::getMapSize(uint32_t *widthMeters, uint32_t *heightMeters
// Convert and store info we need for drawing a marker
// Lat / long to "meters relative to map center", for position on screen
// Info about hopsAway, for marker size
InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float lng, bool hasHopsAway, uint8_t hopsAway)
InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float lng, uint8_t hopsAway)
{
assert(lat != 0 || lng != 0); // Not null island. Applets should check this before calling.
@@ -369,11 +737,9 @@ InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float ln
float northMeters = cos(bearingFromCenter) * distanceFromCenter;
float eastMeters = sin(bearingFromCenter) * distanceFromCenter;
// Store this as a new marker
Marker m;
m.eastMeters = eastMeters;
m.northMeters = northMeters;
m.hasHopsAway = hasHopsAway;
m.hopsAway = hopsAway;
return m;
}
@@ -385,11 +751,7 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
meshtastic_PositionLite pos;
const bool hasPos = nodeDB->copyNodePosition(node->num, pos);
assert(hasPos);
Marker m = calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
node->has_hops_away, // Is the hopsAway number valid
node->hops_away // Hops away
);
Marker m = calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away);
// Convert to pixel coords
int16_t markerX = X(0.5) + (m.eastMeters * metersToPx);
@@ -412,8 +774,6 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
uint8_t markerSize;
bool tooManyHops = node->hops_away > config.lora.hop_limit;
bool isOurNode = node->num == nodeDB->getNodeNum();
bool unknownHops = !node->has_hops_away && !isOurNode;
// Parse any non-ascii chars in the short name,
// and use last 4 instead if unknown / can't render
@@ -426,8 +786,6 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
// Pick emblem style
if (tooManyHops)
markerSize = getTextWidth("!");
else if (unknownHops)
markerSize = markerSizeMin;
else
markerSize = map(node->hops_away, 0, config.lora.hop_limit, markerSizeMax, markerSizeMin);
@@ -482,27 +840,18 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
if (tooManyHops)
printAt(markerX, markerY, "!", CENTER, MIDDLE);
else
drawCross(markerX, markerY, markerSize); // The fewer the hops, the larger the marker. Also handles unknownHops
drawCross(markerX, markerY, markerSize);
}
// Check if we actually have enough nodes which would be shown on the map
// Need at least two, to draw a sensible map
bool InkHUD::MapApplet::enoughMarkers()
{
size_t count = 0;
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Count nodes
if (nodeDB->hasValidPosition(node) && shouldDrawNode(node))
count++;
// We need to find two
if (count == 2)
return true; // Two nodes is enough for a sensible map
return true;
}
return false; // No nodes would be drawn (or just the one, uselessly at 0,0)
return false;
}
// Calculate how far north and east of map center each node is
@@ -529,36 +878,30 @@ void InkHUD::MapApplet::calculateAllMarkers()
if (node->num == nodeDB->getNodeNum())
continue;
// Skip nodes with unknown hop count - partial info, not useful to plot
if (!node->has_hops_away)
continue;
meshtastic_PositionLite pos;
if (!nodeDB->copyNodePosition(node->num, pos))
continue;
// Calculate marker and store it
markers.push_back(calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
node->has_hops_away, // Is the hopsAway number valid
node->hops_away // Hops away
));
markers.push_back(calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away));
}
}
// Determine the conversion factor between metres, and pixels on screen
// May be overridden by derived applet, if custom scale required (fixed map size?)
void InkHUD::MapApplet::calculateMapScale()
{
// Aspect ratio of map and screen
// - larger = wide, smaller = tall
// - used to set scale, so that widest map dimension fits in applet
if (widthMeters == 0 || heightMeters == 0) {
metersToPx = 0;
return;
}
float mapAspectRatio = (float)widthMeters / heightMeters;
float appletAspectRatio = (float)width() / height();
// "Shrink to fit"
// Scale the map so that the largest dimension is fully displayed
// Because aspect ratio will be maintained, the other dimension will appear "padded"
if (mapAspectRatio > appletAspectRatio)
metersToPx = (float)width() / widthMeters; // Too wide for applet. Constrain to fit width.
metersToPx = (float)width() / widthMeters;
else
metersToPx = (float)height() / heightMeters; // Too tall for applet. Constrain to fit height.
metersToPx = (float)height() / heightMeters;
}
// Draw an x, centered on a specific point
@@ -15,10 +15,13 @@ The base applet doesn't handle any events; this is left to the derived applets.
#pragma once
#include "configuration.h"
#include <list>
#include "graphics/niche/InkHUD/Applet.h"
#include "GPSStatus.h"
#include "MeshModule.h"
#include "Observer.h"
#include "gps/GeoCoord.h"
namespace NicheGraphics::InkHUD
@@ -27,33 +30,55 @@ namespace NicheGraphics::InkHUD
class MapApplet : public Applet
{
public:
MapApplet();
void onRender(bool full) override;
MapApplet *asMapApplet() override { return this; } // Identify as MapApplet without RTTI
// Zoom lock - shared across all MapApplet instances (static)
static constexpr int ZOOM_MAX_NO_TILES = 16;
void zoomIn();
void zoomOut();
void resetZoom();
bool isZoomLocked() const { return s_zoomLocked; }
bool canZoomIn() const;
bool canZoomOut() const;
protected:
virtual bool shouldDrawNode(meshtastic_NodeInfoLite *node) { return true; } // Allow derived applets to filter the nodes
virtual void getMapCenter(float *lat, float *lng);
virtual void getMapSize(uint32_t *widthMeters, uint32_t *heightMeters);
bool enoughMarkers(); // Anything to draw?
virtual bool enoughMarkers(); // Anything to draw?
void drawLabeledMarker(meshtastic_NodeInfoLite *node); // Highlight a specific marker
private:
int onGpsStatusUpdate(const meshtastic::Status *status);
CallbackObserver<MapApplet, const meshtastic::Status *> gpsStatusObserver =
CallbackObserver<MapApplet, const meshtastic::Status *>(this, &MapApplet::onGpsStatusUpdate);
static bool s_zoomLocked;
static int s_lockedZoom;
static int s_lastRenderedZoom;
static int s_autoFitZoom; // Zoom chosen by auto-fit (updated whenever not locked)
// Position and size of a marker to be drawn
struct Marker {
float eastMeters = 0; // Meters east of map center. Negative if west.
float northMeters = 0; // Meters north of map center. Negative if south.
bool hasHopsAway = false;
uint8_t hopsAway = 0; // Determines marker size
uint8_t hopsAway = 0; // Determines marker size
};
Marker calculateMarker(float lat, float lng, bool hasHopsAway, uint8_t hopsAway);
Marker calculateMarker(float lat, float lng, uint8_t hopsAway);
void calculateAllMarkers();
void calculateMapScale(); // Conversion factor for meters to pixels
void drawMapTileBackground(int zoom); // Draw georeferenced tile at zoom
void drawCross(int16_t x, int16_t y, uint8_t size); // Draw the X used for most markers
float metersToPx = 0; // Conversion factor for meters to pixels
float latCenter = 0; // Map center: latitude
float lngCenter = 0; // Map center: longitude
float metersToPx = 0; // Conversion factor for meters to pixels
float latCenter = 0; // Map center: latitude
float lngCenter = 0; // Map center: longitude
bool centerIsOurNode = false; // True if map is centered on our own position (GPS or phone)
std::list<Marker> markers;
uint32_t widthMeters = 0; // Map width: meters
@@ -0,0 +1,9 @@
#pragma once
#include <stdint.h>
static const int map_tile_count = 0;
static const int map_tile_zooms[] = {};
static const int map_tile_tx[] = {};
static const int map_tile_ty[] = {};
static const int map_tile_sizes[] = {};
static const uint8_t *map_tile_data[] = {};
@@ -16,6 +16,12 @@ bool usePortraitKeyboardSizing()
InkHUD::KeyboardApplet::KeyboardApplet()
{
for (uint8_t row = 0; row < LEGACY_KBD_ROWS; row++) {
legacyRowWidths[row] = 0;
for (uint8_t col = 0; col < KBD_COLS; col++)
legacyRowWidths[row] += legacyKeyWidths[row * KBD_COLS + col];
}
mode = MODE_TEXT;
lastTypingMode = MODE_TEXT;
emotePage = 0;
@@ -26,6 +32,11 @@ InkHUD::KeyboardApplet::KeyboardApplet()
void InkHUD::KeyboardApplet::onRender(bool full)
{
if (!useTouchKeyboard()) {
renderLegacyKeyboard(full);
return;
}
const bool showSelection = showSelectionHighlight();
if (full) {
@@ -39,6 +50,94 @@ void InkHUD::KeyboardApplet::onRender(bool full)
prevSelectedKey = selectedKey;
}
bool InkHUD::KeyboardApplet::useTouchKeyboard() const
{
return inkhud->hasTouchEnabledProvider();
}
void InkHUD::KeyboardApplet::renderLegacyKeyboard(bool full)
{
uint16_t em = fontSmall.lineHeight();
uint16_t keyH = Y(1.0) / LEGACY_KBD_ROWS;
int16_t keyTopPadding = (keyH - fontSmall.lineHeight()) / 2;
if (full) {
for (uint8_t row = 0; row < LEGACY_KBD_ROWS; row++) {
int16_t keyXPadding = X(1.0) - ((legacyRowWidths[row] * em) >> 4);
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 = (legacyKeyWidths[index] * em) >> 4;
if (index == selectedKey) {
fgcolor = WHITE;
fillRect(keyX, keyY, keyW, keyH, BLACK);
}
drawLegacyKeyLabel(keyX, keyY + keyTopPadding, keyW, legacyKeys[index], fgcolor);
xPos += legacyKeyWidths[index];
}
}
} else if (selectedKey != prevSelectedKey) {
uint8_t row = prevSelectedKey / KBD_COLS;
int16_t keyXPadding = X(1.0) - ((legacyRowWidths[row] * em) >> 4);
uint16_t xPos = 0;
for (uint8_t i = prevSelectedKey - (prevSelectedKey % KBD_COLS); i < prevSelectedKey; i++)
xPos += legacyKeyWidths[i];
uint16_t keyX = ((xPos * em) >> 4) + (((prevSelectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
uint16_t keyY = row * keyH;
uint16_t keyW = (legacyKeyWidths[prevSelectedKey] * em) >> 4;
fillRect(keyX, keyY, keyW, keyH, WHITE);
drawLegacyKeyLabel(keyX, keyY + keyTopPadding, keyW, legacyKeys[prevSelectedKey], BLACK);
row = selectedKey / KBD_COLS;
keyXPadding = X(1.0) - ((legacyRowWidths[row] * em) >> 4);
xPos = 0;
for (uint8_t i = selectedKey - (selectedKey % KBD_COLS); i < selectedKey; i++)
xPos += legacyKeyWidths[i];
keyX = ((xPos * em) >> 4) + (((selectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
keyY = row * keyH;
keyW = (legacyKeyWidths[selectedKey] * em) >> 4;
fillRect(keyX, keyY, keyW, keyH, BLACK);
drawLegacyKeyLabel(keyX, keyY + keyTopPadding, keyW, legacyKeys[selectedKey], WHITE);
}
prevSelectedKey = selectedKey;
}
void InkHUD::KeyboardApplet::drawLegacyKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color)
{
if (key == '\b') {
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') {
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 == ' ') {
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;
std::string keyText = std::string(1, key);
uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
printAt(left + leftPadding, top, keyText);
}
}
void InkHUD::KeyboardApplet::drawKey(uint8_t index, bool selected)
{
uint16_t keyX = 0;
@@ -104,14 +203,40 @@ void InkHUD::KeyboardApplet::onBackground()
void InkHUD::KeyboardApplet::onButtonShortPress()
{
if (!useTouchKeyboard()) {
handleLegacyInput(false);
return;
}
inputSelectedKey(false);
}
void InkHUD::KeyboardApplet::onButtonLongPress()
{
if (!useTouchKeyboard()) {
handleLegacyInput(true);
return;
}
inputSelectedKey(true);
}
void InkHUD::KeyboardApplet::handleLegacyInput(bool longPress)
{
char key = legacyKeys[selectedKey];
if (key == '\n') {
inkhud->freeTextDone();
inkhud->closeKeyboard();
} else if (key == '\x1b') {
inkhud->freeTextCancel();
inkhud->closeKeyboard();
} else {
if (longPress && key >= 0x61)
key -= 32;
inkhud->freeText(key);
}
}
void InkHUD::KeyboardApplet::onExitShort()
{
inkhud->freeTextCancel();
@@ -126,6 +251,17 @@ void InkHUD::KeyboardApplet::onExitLong()
void InkHUD::KeyboardApplet::onNavUp()
{
if (!useTouchKeyboard()) {
if (selectedKey < KBD_COLS)
selectedKey += KBD_COLS * (LEGACY_KBD_ROWS - 1);
else
selectedKey -= KBD_COLS;
requestUpdate(EInk::UpdateTypes::FAST, false);
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
return;
}
if (selectedKey < KBD_COLS)
selectedKey += KBD_COLS * (KBD_ROWS - 1);
else
@@ -137,6 +273,14 @@ void InkHUD::KeyboardApplet::onNavUp()
void InkHUD::KeyboardApplet::onNavDown()
{
if (!useTouchKeyboard()) {
selectedKey += KBD_COLS;
selectedKey %= LEGACY_KBD_KEY_COUNT;
requestUpdate(EInk::UpdateTypes::FAST, false);
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
return;
}
selectedKey += KBD_COLS;
selectedKey %= KBD_KEY_COUNT;
normalizeSelection();
@@ -145,6 +289,17 @@ void InkHUD::KeyboardApplet::onNavDown()
void InkHUD::KeyboardApplet::onNavLeft()
{
if (!useTouchKeyboard()) {
if (selectedKey % KBD_COLS == 0)
selectedKey += KBD_COLS - 1;
else
selectedKey--;
requestUpdate(EInk::UpdateTypes::FAST, false);
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
return;
}
if (selectedKey % KBD_COLS == 0)
selectedKey += KBD_COLS - 1;
else
@@ -156,6 +311,17 @@ void InkHUD::KeyboardApplet::onNavLeft()
void InkHUD::KeyboardApplet::onNavRight()
{
if (!useTouchKeyboard()) {
if (selectedKey % KBD_COLS == KBD_COLS - 1)
selectedKey -= KBD_COLS - 1;
else
selectedKey++;
requestUpdate(EInk::UpdateTypes::FAST, false);
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
return;
}
if (selectedKey % KBD_COLS == KBD_COLS - 1)
selectedKey -= KBD_COLS - 1;
else
@@ -418,6 +584,12 @@ bool InkHUD::KeyboardApplet::isKeyEnabledAt(uint8_t index) const
void InkHUD::KeyboardApplet::normalizeSelection()
{
if (!useTouchKeyboard()) {
if (selectedKey >= LEGACY_KBD_KEY_COUNT)
selectedKey = 0;
return;
}
if (selectedKey >= KBD_KEY_COUNT)
selectedKey = 0;
@@ -492,6 +664,10 @@ bool InkHUD::KeyboardApplet::showSelectionHighlight() const
uint16_t InkHUD::KeyboardApplet::getKeyboardHeight()
{
const auto *hud = NicheGraphics::InkHUD::InkHUD::getInstance();
if (!hud || !hud->hasTouchEnabledProvider())
return static_cast<uint16_t>(fontSmall.lineHeight() * 1.2f) * LEGACY_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;
@@ -37,6 +37,11 @@ class KeyboardApplet : public SystemApplet
static uint16_t getKeyboardHeight(); // used to set the keyboard tile height
private:
bool useTouchKeyboard() const;
void renderLegacyKeyboard(bool full);
void handleLegacyInput(bool longPress);
void drawLegacyKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color);
enum KeyCode : int16_t {
KEY_NONE = -1,
KEY_BACKSPACE = 256,
@@ -69,6 +74,8 @@ class KeyboardApplet : public SystemApplet
bool showSelectionHighlight() const;
static const uint8_t KBD_COLS = 11;
static const uint8_t LEGACY_KBD_ROWS = 4;
static const uint8_t LEGACY_KBD_KEY_COUNT = KBD_COLS * LEGACY_KBD_ROWS;
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
@@ -137,6 +144,22 @@ class KeyboardApplet : public SystemApplet
static constexpr uint8_t KEY_GAP_X = 3;
static constexpr uint8_t KEY_GAP_Y = 4;
static constexpr uint8_t KEY_RADIUS = 4;
const char legacyKeys[LEGACY_KBD_KEY_COUNT] = {
'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
};
const uint16_t legacyKeyWidths[LEGACY_KBD_KEY_COUNT] = {
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
};
uint16_t legacyRowWidths[LEGACY_KBD_ROWS];
};
} // namespace NicheGraphics::InkHUD
@@ -26,6 +26,11 @@ enum MenuAction {
NEXT_TILE,
TOGGLE_BACKLIGHT,
TOGGLE_GPS,
TOGGLE_SMART_POSITION,
SET_POSITION_BROADCAST_INTERVAL,
SET_SMART_BROADCAST_INTERVAL,
SET_SMART_BROADCAST_DISTANCE,
SET_GPS_UPDATE_INTERVAL,
ENABLE_BLUETOOTH,
TOGGLE_APPLET,
TOGGLE_AUTOSHOW_APPLET,
@@ -70,6 +75,9 @@ enum MenuAction {
SET_REGION_ITU2_2M,
SET_REGION_ITU3_2M,
SET_REGION_ITU2_125CM,
SET_REGION_ITU1_70CM,
SET_REGION_ITU2_70CM,
SET_REGION_ITU3_70CM,
// Device Roles
SET_ROLE_CLIENT,
SET_ROLE_CLIENT_MUTE,
@@ -129,6 +137,11 @@ enum MenuAction {
// Administration
RESET_NODEDB_ALL,
RESET_NODEDB_KEEP_FAVORITES,
WIPE_MESSAGES_ALL,
// Map zoom (MapApplet and FavoritesMapApplet)
MAP_ZOOM_IN,
MAP_ZOOM_OUT,
MAP_ZOOM_RESET,
};
} // namespace NicheGraphics::InkHUD
@@ -6,9 +6,11 @@
#include "GPS.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "MessageStore.h"
#include "RTC.h"
#include "Router.h"
#include "airtime.h"
#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h"
#include "graphics/niche/Utils/FlashData.h"
#include "main.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
@@ -34,11 +36,36 @@ struct DisplayTimeoutOption {
const char *label;
};
struct UInt32Option {
uint32_t value;
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"},
};
static constexpr UInt32Option POSITION_BROADCAST_OPTIONS[] = {
{60, "1 min"}, {90, "90 sec"}, {5 * 60, "5 min"}, {15 * 60, "15 min"},
{60 * 60, "1 hr"}, {2 * 60 * 60, "2 hr"}, {3 * 60 * 60, "3 hr"}, {4 * 60 * 60, "4 hr"},
{5 * 60 * 60, "5 hr"}, {6 * 60 * 60, "6 hr"}, {12 * 60 * 60, "12 hr"}, {18 * 60 * 60, "18 hr"},
{24 * 60 * 60, "24 hr"}, {36 * 60 * 60, "36 hr"}, {48 * 60 * 60, "48 hr"}, {72 * 60 * 60, "72 hr"},
};
static constexpr UInt32Option GPS_UPDATE_INTERVAL_OPTIONS[] = {
{8, "8 sec"}, {20, "20 sec"}, {40, "40 sec"}, {60, "1 min"}, {80, "80 sec"},
{2 * 60, "2 min"}, {5 * 60, "5 min"}, {10 * 60, "10 min"}, {15 * 60, "15 min"}, {30 * 60, "30 min"},
{60 * 60, "1 hr"}, {6 * 60 * 60, "6 hr"}, {12 * 60 * 60, "12 hr"}, {24 * 60 * 60, "24 hr"}, {2147483647UL, "At Boot"},
};
static constexpr UInt32Option SMART_INTERVAL_OPTIONS[] = {
{5 * 60, "5 min"}, {10 * 60, "10 min"}, {15 * 60, "15 min"}, {30 * 60, "30 min"}, {60 * 60, "1 hr"},
{2 * 60 * 60, "2 hr"}, {6 * 60 * 60, "6 hr"}, {12 * 60 * 60, "12 hr"}, {24 * 60 * 60, "24 hr"},
};
static constexpr uint32_t SMART_DISTANCE_OPTIONS[] = {20, 50, 100, 250, 500, 1000, 2000, 5000};
struct PositionPrecisionOption {
uint8_t value; // proto value
const char *metric;
@@ -63,6 +90,30 @@ static const char *getDisplayTimeoutLabel(uint32_t timeoutSeconds)
return "Custom";
}
static std::string getUInt32OptionLabel(const UInt32Option *options, uint8_t optionCount, uint32_t value,
const char *zeroLabel = nullptr)
{
for (uint8_t i = 0; i < optionCount; i++) {
if (options[i].value == value) {
return options[i].label;
}
}
if (value == 0) {
return zeroLabel ? zeroLabel : "0 sec";
}
if (value == 2147483647UL) {
return "At Boot";
}
if (value % (60 * 60) == 0) {
return std::to_string(value / (60 * 60)) + " hr";
}
if (value % 60 == 0) {
return std::to_string(value / 60) + " min";
}
return std::to_string(value) + " sec";
}
static bool supportsFreeTextKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
{
return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
@@ -258,7 +309,7 @@ int32_t InkHUD::MenuApplet::runOnce()
return OSThread::disable();
}
// Storage for the dynamically-built region preset list populated in showPage(NODE_CONFIG_PRESET)
// Storage for the dynamically-built region preset list - populated in showPage(NODE_CONFIG_PRESET)
static constexpr uint8_t MAX_REGION_PRESETS = 16;
static meshtastic_Config_LoRaConfig_ModemPreset regionPresets[MAX_REGION_PRESETS];
static uint8_t regionPresetCount = 0;
@@ -331,6 +382,17 @@ static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}
static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = false)
{
nodeDB->saveToDisk(changes);
service->reloadConfig(changes);
if (reboot) {
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}
}
static const char *getTimezoneLabelFromValue(const char *tzdef)
{
if (!tzdef || !*tzdef)
@@ -557,6 +619,51 @@ void InkHUD::MenuApplet::execute(MenuItem item)
#endif
break;
case TOGGLE_SMART_POSITION:
config.position.position_broadcast_smart_enabled = !config.position.position_broadcast_smart_enabled;
applyConfigReload(SEGMENT_CONFIG, true);
break;
case SET_POSITION_BROADCAST_INTERVAL: {
const uint8_t index = cursor - 1;
constexpr uint8_t optionCount = sizeof(POSITION_BROADCAST_OPTIONS) / sizeof(POSITION_BROADCAST_OPTIONS[0]);
if (index < optionCount && config.position.position_broadcast_secs != POSITION_BROADCAST_OPTIONS[index].value) {
config.position.position_broadcast_secs = POSITION_BROADCAST_OPTIONS[index].value;
applyConfigReload(SEGMENT_CONFIG, true);
}
break;
}
case SET_SMART_BROADCAST_INTERVAL: {
const uint8_t index = cursor - 1;
constexpr uint8_t optionCount = sizeof(SMART_INTERVAL_OPTIONS) / sizeof(SMART_INTERVAL_OPTIONS[0]);
if (index < optionCount && config.position.broadcast_smart_minimum_interval_secs != SMART_INTERVAL_OPTIONS[index].value) {
config.position.broadcast_smart_minimum_interval_secs = SMART_INTERVAL_OPTIONS[index].value;
applyConfigReload(SEGMENT_CONFIG, true);
}
break;
}
case SET_SMART_BROADCAST_DISTANCE: {
const uint8_t index = cursor - 1;
constexpr uint8_t optionCount = sizeof(SMART_DISTANCE_OPTIONS) / sizeof(SMART_DISTANCE_OPTIONS[0]);
if (index < optionCount && config.position.broadcast_smart_minimum_distance != SMART_DISTANCE_OPTIONS[index]) {
config.position.broadcast_smart_minimum_distance = SMART_DISTANCE_OPTIONS[index];
applyConfigReload(SEGMENT_CONFIG, true);
}
break;
}
case SET_GPS_UPDATE_INTERVAL: {
const uint8_t index = cursor - 1;
constexpr uint8_t optionCount = sizeof(GPS_UPDATE_INTERVAL_OPTIONS) / sizeof(GPS_UPDATE_INTERVAL_OPTIONS[0]);
if (index < optionCount && config.position.gps_update_interval != GPS_UPDATE_INTERVAL_OPTIONS[index].value) {
config.position.gps_update_interval = GPS_UPDATE_INTERVAL_OPTIONS[index].value;
applyConfigReload(SEGMENT_CONFIG, true);
}
break;
}
case ENABLE_BLUETOOTH:
// This helps users recover from a bad wifi config
LOG_INFO("Enabling Bluetooth");
@@ -800,6 +907,18 @@ void InkHUD::MenuApplet::execute(MenuItem item)
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM);
break;
case SET_REGION_ITU1_70CM:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU1_70CM);
break;
case SET_REGION_ITU2_70CM:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_70CM);
break;
case SET_REGION_ITU3_70CM:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM);
break;
// Roles
case SET_ROLE_CLIENT:
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT);
@@ -1013,6 +1132,34 @@ void InkHUD::MenuApplet::execute(MenuItem item)
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
break;
case WIPE_MESSAGES_ALL:
LOG_INFO("Wiping all messages from menu");
messageStore.clearAllMessages();
inkhud->persistence->loadLatestMessage();
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
break;
case MAP_ZOOM_IN: {
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet)
mapApplet->zoomIn();
break;
}
case MAP_ZOOM_OUT: {
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet)
mapApplet->zoomOut();
break;
}
case MAP_ZOOM_RESET: {
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet)
mapApplet->resetZoom();
break;
}
default:
LOG_WARN("Action not implemented");
}
@@ -1038,6 +1185,20 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
items.push_back(MenuItem("Next Tile", MenuAction::NEXT_TILE, MenuPage::ROOT)); // Only if multiple applets shown
items.push_back(MenuItem("Send", MenuPage::SEND));
// Map zoom controls - only when viewing a map applet
{
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet) {
if (mapApplet->canZoomIn())
items.push_back(MenuItem("Zoom In", MenuAction::MAP_ZOOM_IN, MenuPage::EXIT));
if (mapApplet->canZoomOut())
items.push_back(MenuItem("Zoom Out", MenuAction::MAP_ZOOM_OUT, MenuPage::EXIT));
if (mapApplet->isZoomLocked())
items.push_back(MenuItem("Reset Zoom", MenuAction::MAP_ZOOM_RESET, MenuPage::EXIT));
}
}
items.push_back(MenuItem("Options", MenuPage::OPTIONS));
// items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO
items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG));
@@ -1130,6 +1291,7 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
// Administration Section
items.push_back(MenuItem::Header("Administration"));
items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
items.push_back(MenuItem("Wipe Messages", MenuPage::NODE_CONFIG_ADMIN_MESSAGES));
// Exit
items.push_back(MenuItem("Exit", MenuPage::EXIT));
@@ -1154,6 +1316,9 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
case NODE_CONFIG_POSITION: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem::Header("Device GPS"));
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
const auto mode = config.position.gps_mode;
if (mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
@@ -1161,12 +1326,93 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
} else {
gpsEnabled = (mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED);
items.push_back(MenuItem("GPS", MenuAction::TOGGLE_GPS, MenuPage::NODE_CONFIG_POSITION, &gpsEnabled));
nodeConfigLabels.emplace_back(
"GPS Poll: " + getUInt32OptionLabel(GPS_UPDATE_INTERVAL_OPTIONS,
sizeof(GPS_UPDATE_INTERVAL_OPTIONS) / sizeof(GPS_UPDATE_INTERVAL_OPTIONS[0]),
config.position.gps_update_interval, "Default"));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION,
MenuPage::NODE_CONFIG_POSITION_GPS_UPDATE_INTERVAL));
}
#endif
items.push_back(MenuItem::Header("Position Packet"));
nodeConfigLabels.emplace_back(
"Broadcast: " + getUInt32OptionLabel(POSITION_BROADCAST_OPTIONS,
sizeof(POSITION_BROADCAST_OPTIONS) / sizeof(POSITION_BROADCAST_OPTIONS[0]),
config.position.position_broadcast_secs));
items.push_back(
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION_BROADCAST_INTERVAL));
items.push_back(MenuItem("Smart Pos", MenuAction::TOGGLE_SMART_POSITION, MenuPage::NODE_CONFIG_POSITION,
&config.position.position_broadcast_smart_enabled));
if (config.position.position_broadcast_smart_enabled) {
nodeConfigLabels.emplace_back("Smart Int: " +
getUInt32OptionLabel(SMART_INTERVAL_OPTIONS,
sizeof(SMART_INTERVAL_OPTIONS) / sizeof(SMART_INTERVAL_OPTIONS[0]),
config.position.broadcast_smart_minimum_interval_secs));
items.push_back(
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION_SMART_INTERVAL));
nodeConfigLabels.emplace_back("Smart Dist: " + localizeDistance(config.position.broadcast_smart_minimum_distance));
items.push_back(
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION_SMART_DISTANCE));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_POSITION_BROADCAST_INTERVAL:
previousPage = MenuPage::NODE_CONFIG_POSITION;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem::Header("Max time between sends"));
for (const auto &option : POSITION_BROADCAST_OPTIONS) {
nodeConfigLabels.emplace_back(option.label);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_POSITION_BROADCAST_INTERVAL,
MenuPage::NODE_CONFIG_POSITION));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_POSITION_SMART_INTERVAL:
previousPage = MenuPage::NODE_CONFIG_POSITION;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem::Header("Fastest smart resend"));
for (const auto &option : SMART_INTERVAL_OPTIONS) {
nodeConfigLabels.emplace_back(option.label);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_SMART_BROADCAST_INTERVAL,
MenuPage::NODE_CONFIG_POSITION));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_POSITION_SMART_DISTANCE:
previousPage = MenuPage::NODE_CONFIG_POSITION;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem::Header("Move this far to send"));
for (const auto &option : SMART_DISTANCE_OPTIONS) {
nodeConfigLabels.emplace_back(localizeDistance(option));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_SMART_BROADCAST_DISTANCE,
MenuPage::NODE_CONFIG_POSITION));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_POSITION_GPS_UPDATE_INTERVAL:
previousPage = MenuPage::NODE_CONFIG_POSITION;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem::Header("GPS poll cadence"));
for (const auto &option : GPS_UPDATE_INTERVAL_OPTIONS) {
nodeConfigLabels.emplace_back(option.label);
items.push_back(
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_GPS_UPDATE_INTERVAL, MenuPage::NODE_CONFIG_POSITION));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_POWER: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
@@ -1534,6 +1780,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_ADMIN_MESSAGES:
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("Wipe All Messages", MenuAction::WIPE_MESSAGES_ALL, MenuPage::EXIT));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
// Exit
case EXIT:
sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
@@ -2272,26 +2525,29 @@ void InkHUD::MenuApplet::drawSystemInfoPanel(int16_t left, int16_t top, uint16_t
const int16_t divY = top + height;
height += fontSmall.lineHeight() * 0.2; // Padding *below* the divider. (Above first menu item)
// Create a variable number of columns
// Either 3 or 4, depending on whether we have GPS
// Todo
constexpr uint8_t N_COL = 3;
int16_t colL[N_COL];
int16_t colC[N_COL];
int16_t colR[N_COL];
for (uint8_t i = 0; i < N_COL; i++) {
colL[i] = left + ((width / N_COL) * i);
colC[i] = colL[i] + ((width / N_COL) / 2);
colR[i] = colL[i] + (width / N_COL);
// Create a variable number of columns.
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
const bool showGpsInfo = config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT;
#else
const bool showGpsInfo = false;
#endif
const uint8_t columnCount = showGpsInfo ? 4 : 3;
int16_t colL[4] = {};
int16_t colC[4] = {};
int16_t colR[4] = {};
for (uint8_t i = 0; i < columnCount; i++) {
colL[i] = left + ((width / columnCount) * i);
colC[i] = colL[i] + ((width / columnCount) / 2);
colR[i] = colL[i] + (width / columnCount);
}
// Info blocks, left to right
// Voltage
float voltage = powerStatus->getBatteryVoltageMv() / 1000.0;
char voltageStr[6]; // "XX.XV"
sprintf(voltageStr, "%.2fV", voltage);
printAt(colC[0], labelT, "Bat", CENTER, TOP);
char voltageStr[8]; // e.g. "4.12"
snprintf(voltageStr, sizeof(voltageStr), "%.2f", voltage);
printAt(colC[0], labelT, "Bat V", CENTER, TOP);
printAt(colC[0], valT, voltageStr, CENTER, TOP);
// Divider
@@ -2299,8 +2555,8 @@ void InkHUD::MenuApplet::drawSystemInfoPanel(int16_t left, int16_t top, uint16_t
drawPixel(colR[0], y, BLACK);
// Channel Util
char chUtilStr[4]; // "XX%"
sprintf(chUtilStr, "%2.f%%", airTime->channelUtilizationPercent());
char chUtilStr[8]; // e.g. "100%"
snprintf(chUtilStr, sizeof(chUtilStr), "%2.f%%", airTime->channelUtilizationPercent());
printAt(colC[1], labelT, "Ch", CENTER, TOP);
printAt(colC[1], valT, chUtilStr, CENTER, TOP);
@@ -2309,20 +2565,34 @@ void InkHUD::MenuApplet::drawSystemInfoPanel(int16_t left, int16_t top, uint16_t
drawPixel(colR[1], y, BLACK);
// Duty Cycle (AirTimeTx)
char dutyUtilStr[4]; // "XX%"
sprintf(dutyUtilStr, "%2.f%%", airTime->utilizationTXPercent());
char dutyUtilStr[8]; // e.g. "100%"
snprintf(dutyUtilStr, sizeof(dutyUtilStr), "%2.f%%", airTime->utilizationTXPercent());
printAt(colC[2], labelT, "Duty", CENTER, TOP);
printAt(colC[2], valT, dutyUtilStr, CENTER, TOP);
/*
// Divider
for (int16_t y = valT; y <= divY; y += 3)
drawPixel(colR[2], y, BLACK);
if (showGpsInfo) {
// Divider
for (int16_t y = valT; y <= divY; y += 3)
drawPixel(colR[2], y, BLACK);
// GPS satellites - todo
printAt(colC[3], labelT, "Sats", CENTER, TOP);
printAt(colC[3], valT, "ToDo", CENTER, TOP);
*/
const bool gpsDisabled = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED;
const char *gpsLabel = "GPS";
printAt(colC[3], labelT, gpsLabel, CENTER, TOP);
if (gpsDisabled) {
const int16_t labelW = getTextWidth(gpsLabel);
const int16_t strikeY = labelT + (fontSmall.lineHeight() / 2);
drawLine(colC[3] - (labelW / 2), strikeY, colC[3] + ((labelW - 1) / 2), strikeY, BLACK);
}
if (!gpsDisabled && gpsStatus != nullptr && gpsStatus->getIsConnected()) {
char satsStr[12];
snprintf(satsStr, sizeof(satsStr), "%lu", (unsigned long)gpsStatus->getNumSatellites());
printAt(colC[3], valT, satsStr, CENTER, TOP);
} else {
printAt(colC[3], valT, "--", CENTER, TOP);
}
}
// Horizontal divider, at bottom of system info panel
for (int16_t x = 0; x < width; x += 2) // Divider, centered in the padding between first system panel and first item
@@ -35,7 +35,12 @@ enum MenuPage : uint8_t {
NODE_CONFIG_DISPLAY_TIMEOUT,
NODE_CONFIG_BLUETOOTH,
NODE_CONFIG_POSITION,
NODE_CONFIG_POSITION_BROADCAST_INTERVAL,
NODE_CONFIG_POSITION_SMART_INTERVAL,
NODE_CONFIG_POSITION_SMART_DISTANCE,
NODE_CONFIG_POSITION_GPS_UPDATE_INTERVAL,
NODE_CONFIG_ADMIN_RESET,
NODE_CONFIG_ADMIN_MESSAGES,
TIMEZONE,
APPLETS,
AUTOSHOW,
@@ -2,6 +2,7 @@
#include "./FavoritesMapApplet.h"
#include "NodeDB.h"
#include "configuration.h"
using namespace NicheGraphics;
@@ -11,6 +12,15 @@ bool InkHUD::FavoritesMapApplet::shouldDrawNode(meshtastic_NodeInfoLite *node)
return node && (node->num == nodeDB->getNodeNum() || nodeInfoLiteIsFavorite(node));
}
// Show map as long as our own node has a position, even with no favorites yet.
bool InkHUD::FavoritesMapApplet::enoughMarkers()
{
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
if (ourNode && nodeDB->hasValidPosition(ourNode))
return true;
return MapApplet::enoughMarkers();
}
void InkHUD::FavoritesMapApplet::onRender(bool full)
{
// Custom empty state text for favorites-only map.
@@ -27,7 +27,10 @@ class FavoritesMapApplet : public MapApplet, public SinglePortModule
void onRender(bool full) override;
protected:
void onActivate() override { loopbackOk = true; }
void onDeactivate() override { loopbackOk = false; }
bool shouldDrawNode(meshtastic_NodeInfoLite *node) override;
bool enoughMarkers() override;
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
NodeNum lastFrom = 0; // Sender of most recent favorited (non-local) position packet
@@ -27,6 +27,8 @@ class PositionsApplet : public MapApplet, public SinglePortModule
void onRender(bool full) override;
protected:
void onActivate() override { loopbackOk = true; }
void onDeactivate() override { loopbackOk = false; }
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
NodeNum lastFrom = 0; // Sender of most recent (non-local) position packet
@@ -190,7 +190,7 @@ ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_Me
if (mp.to != NODENUM_BROADCAST)
return ProcessMessage::CONTINUE;
// Store in the global messageStore this handles sender, timestamp, channel, text, and ack status
// Store in the global messageStore - this handles sender, timestamp, channel, text, and ack status
messageStore.addFromPacket(mp);
// If this was an incoming message, suggest that our applet becomes foreground, if permitted
+3 -1
View File
@@ -22,6 +22,8 @@ void InkHUD::Persistence::loadSettings()
// are immediately available to applets (DMApplet, AllMessageApplet, NotificationApplet).
void InkHUD::Persistence::loadLatestMessage()
{
latestMessage = LatestMessage();
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
for (const StoredMessage &m : messageStore.getLiveMessages()) {
if (m.type == MessageType::BROADCAST) {
@@ -75,4 +77,4 @@ void InkHUD::Persistence::printSettings(Settings *settings)
}
*/
#endif
#endif
+23 -2
View File
@@ -254,6 +254,27 @@ You will need to add these lines to any variants which will use your applet.
If you need to create several similar applets, it might make sense to create a reusable base class. Several of these already exist in `src/graphics/niche/InkHUD/Applets/Bases`, but use these with caution, as they may be modified in future.
#### Map Applet Base
`MapApplet` (`src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h`) is a base class for applets that plot node positions on a map. It handles tile rendering, zoom control, scale bars, and GPS tracking. `PositionsApplet` and `FavoritesMapApplet` both inherit from it.
##### Map Tiles
Map tiles are stored in `MapTile.h` (`src/graphics/niche/InkHUD/Applets/Bases/Map/MapTile.h`). The file committed to the repository contains no tile data by default - the map applets work without tiles, falling back to the original marker-only display.
Tiles are 256×256 pixels, 1-bit (column-major bit packing), compressed per tile with LZ4 to keep flash usage low.
##### Zoom Controls
When the menu is opened from a map applet, zoom controls appear automatically. Zoom In and Zoom Out step through the available tile zoom levels. Reset Zoom returns to the default auto-fit behavior, where the map scales to show all visible nodes.
##### Position Menu
InkHUD's `Node Config -> Position` page now exposes the common position controls directly in the menu applet, using the same compact selector pattern as other InkHUD config pages.
- Device GPS controls: GPS enable/disable, GPS polling interval
- Position packet controls: broadcast interval, smart position toggle, smart minimum interval, smart minimum distance
#### System Applets
So far, we have been talking about "user applets". We also recognize a separate category of "system applets". These handle things like the menu, and the boot screen. These often need special handling, and need to be implemented manually.
@@ -475,8 +496,8 @@ We keep this separate latest-message cache for this purpose, because:
Broadcasts and DMs take different paths into `messageStore`:
- **Broadcasts** `ThreadedMessageApplet::handleReceived()` calls `messageStore.addFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`.
- **DMs** `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.addFromPacket()` directly and stores the result in `latestMessage.dm`.
- **Broadcasts** - `ThreadedMessageApplet::handleReceived()` calls `messageStore.addFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`.
- **DMs** - `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.addFromPacket()` directly and stores the result in `latestMessage.dm`.
#### Saving / Loading
+97
View File
@@ -0,0 +1,97 @@
#include "HapticFeedback.h"
#ifdef HAPTIC_FEEDBACK_PIN
#include <Arduino.h>
#ifdef HAPTIC_FEEDBACK_ACTIVE_LOW
#define HAPTIC_FEEDBACK_ON_STATE LOW
#define HAPTIC_FEEDBACK_OFF_STATE HIGH
#else
#define HAPTIC_FEEDBACK_ON_STATE HIGH
#define HAPTIC_FEEDBACK_OFF_STATE LOW
#endif
HapticFeedback *hapticFeedback = nullptr;
void initHapticFeedback()
{
if (!hapticFeedback)
hapticFeedback = new HapticFeedback();
}
HapticFeedback::HapticFeedback() : concurrency::OSThread("Haptic")
{
pinMode(HAPTIC_FEEDBACK_PIN, OUTPUT);
digitalWrite(HAPTIC_FEEDBACK_PIN, HAPTIC_FEEDBACK_OFF_STATE);
}
void HapticFeedback::motorWrite(bool on)
{
digitalWrite(HAPTIC_FEEDBACK_PIN, on ? HAPTIC_FEEDBACK_ON_STATE : HAPTIC_FEEDBACK_OFF_STATE);
}
void HapticFeedback::pulse(uint16_t durationMs)
{
motorWrite(true);
pulseOffAt = millis() + durationMs;
if (pulseOffAt == 0) // 0 is the "no pulse" sentinel
pulseOffAt = 1;
scheduleNext();
}
void HapticFeedback::armDelayedPulse(uint16_t delayMs, uint16_t durationMs)
{
delayedPulseAt = millis() + delayMs;
if (delayedPulseAt == 0)
delayedPulseAt = 1;
delayedPulseDuration = durationMs;
scheduleNext();
}
void HapticFeedback::cancelDelayedPulse()
{
delayedPulseAt = 0;
}
void HapticFeedback::scheduleNext()
{
uint32_t now = millis();
uint32_t next = 0;
if (pulseOffAt != 0)
next = pulseOffAt;
if (delayedPulseAt != 0 && (next == 0 || (int32_t)(delayedPulseAt - next) < 0))
next = delayedPulseAt;
if (next == 0)
return;
int32_t delay = (int32_t)(next - now);
setIntervalFromNow(delay > 0 ? (unsigned long)delay : 0);
}
int32_t HapticFeedback::runOnce()
{
uint32_t now = millis();
if (pulseOffAt != 0 && (int32_t)(now - pulseOffAt) >= 0) {
motorWrite(false);
pulseOffAt = 0;
}
if (delayedPulseAt != 0 && (int32_t)(now - delayedPulseAt) >= 0) {
uint16_t dur = delayedPulseDuration;
delayedPulseAt = 0;
pulse(dur);
}
uint32_t next = 0;
if (pulseOffAt != 0)
next = pulseOffAt;
if (delayedPulseAt != 0 && (next == 0 || (int32_t)(delayedPulseAt - next) < 0))
next = delayedPulseAt;
if (next == 0)
return 60 * 1000;
int32_t delay = (int32_t)(next - now);
return delay > 0 ? delay : 0;
}
#endif // HAPTIC_FEEDBACK_PIN
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "configuration.h"
#ifdef HAPTIC_FEEDBACK_PIN
#include "concurrency/OSThread.h"
#include <stdint.h>
// Non-blocking pulses on a GPIO vibration motor. HAPTIC_FEEDBACK_ACTIVE_LOW inverts polarity.
class HapticFeedback : public concurrency::OSThread
{
public:
HapticFeedback();
void pulse(uint16_t durationMs = 30);
void armDelayedPulse(uint16_t delayMs, uint16_t durationMs = 30);
void cancelDelayedPulse();
protected:
int32_t runOnce() override;
private:
uint32_t pulseOffAt = 0;
uint32_t delayedPulseAt = 0;
uint16_t delayedPulseDuration = 0;
void motorWrite(bool on);
// Reschedule to the soonest pending event so later arms don't clobber earlier wakes.
void scheduleNext();
};
extern HapticFeedback *hapticFeedback;
void initHapticFeedback();
#endif // HAPTIC_FEEDBACK_PIN
+15 -4
View File
@@ -2,6 +2,7 @@
#include "PowerFSM.h" // needed for event trigger
#include "configuration.h"
#include "graphics/Screen.h"
#include "input/HapticFeedback.h"
#include "modules/ExternalNotificationModule.h"
#ifdef MESHTASTIC_LOCKDOWN
#include "security/LockdownDisplay.h"
@@ -128,14 +129,14 @@ int InputBroker::handleInputEvent(const InputEvent *event)
#ifdef MESHTASTIC_LOCKDOWN
// Lockdown: when the display is redacted (storage locked, or screen-lock
// latch set after idle) the screen content is hidden, but local input
// would otherwise still flow into UI handlers letting an operator
// would otherwise still flow into UI handlers - letting an operator
// drive menus, fire canned messages, change settings etc. blind. Eat
// the event here so input is no-op until the redaction clears.
// The latch is cleared only by unlockScreen() on a successful
// passphrase auth (see PhoneAPI::handleLockdownAuthInline) local
// passphrase auth (see PhoneAPI::handleLockdownAuthInline) - local
// input does not clear it, even if storage happens to be unlocked.
// PowerFSM was already triggered above, so the backlight still wakes
// to show the LOCKED frame the input just doesn't act on anything.
// to show the LOCKED frame - the input just doesn't act on anything.
if (meshtastic_security::shouldRedactDisplay()) {
return 0;
}
@@ -256,6 +257,16 @@ void InputBroker::Init()
}
touchBacklightActive = false;
};
#endif
#if defined(HAPTIC_FEEDBACK_PIN)
// Blip on touch, second blip when long-press fires (500 ms = touchConfig.longPressTime default).
touchConfig.suppressLeadUpSound = true;
initHapticFeedback();
touchConfig.onPress = []() {
hapticFeedback->pulse(80);
hapticFeedback->armDelayedPulse(500, 80);
};
touchConfig.onRelease = []() { hapticFeedback->cancelDelayedPulse(); };
#endif
TouchButtonThread->initButton(touchConfig);
#endif
@@ -416,7 +427,7 @@ void InputBroker::Init()
}
}
#ifdef __linux__
// Linux evdev keyboard input only macOS has no <linux/input.h>.
// Linux evdev keyboard input only - macOS has no <linux/input.h>.
aLinuxInputImpl = new LinuxInputImpl();
aLinuxInputImpl->init();
#endif
+6
View File
@@ -66,8 +66,14 @@ void TouchScreenBase::init(bool hasTouch)
int32_t TouchScreenBase::runOnce()
{
uint32_t nowMs = millis();
if (nowMs - _lastRun < 20) { // suppress too fast consecutive runOnce() executions
return 20;
}
_lastRun = nowMs;
TouchEvent e;
e.touchEvent = static_cast<char>(TOUCH_ACTION_NONE);
this->setInterval(TOUCH_POLL_INTERVAL_IDLE);
const bool fastTapMode = fastTapModeEnabled();
const bool allowLongPress = longPressEnabled();
+1
View File
@@ -53,6 +53,7 @@ class TouchScreenBase : public Observable<const InputEvent *>, public concurrenc
time_t _start; // for LONG_PRESS
uint32_t _lastTouchSeenMs; // helps suppress brief touch-controller dropouts
bool _tapped; // for DOUBLE_TAP
uint32_t _lastRun = 0; // helps suppress too fast consecutive runOnce() executions
const char *_originName;
};
+62
View File
@@ -2,7 +2,9 @@
#include "InputBroker.h"
#include "PowerFSM.h"
#include "configuration.h"
#include "main.h"
#include "modules/ExternalNotificationModule.h"
#include "sleep.h"
#include <cstring>
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
@@ -16,6 +18,28 @@
TouchScreenImpl1 *touchScreenImpl1;
// Hardware-interrupt wake on the touch IRQ line. Some touch boards
// either drive this pin differently (RAK14014 already owns this interrupt in TFTDisplay) or
// route it through an IO expander, which can't be used with attachInterrupt().
// use ENABLE_TOUCH_INT to indicate that we should enable the interrupt here.
#if defined(SCREEN_TOUCH_INT) && defined(ENABLE_TOUCH_INT)
// The touch controller pulls SCREEN_TOUCH_INT low when a new touch begins. Wake the polling
// thread immediately so the touch is handled without waiting for the next idle poll. The
// periodic poll in TouchScreenBase::runOnce() remains as a fallback. Mirrors the button
// interrupt handling in ButtonThread/InputBroker.
static void touchScreenInterruptHandler()
{
if (touchScreenImpl1) {
touchScreenImpl1->setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
concurrency::mainDelay.interruptFromISR(&higherWake);
}
}
#endif
TouchScreenImpl1::TouchScreenImpl1(uint16_t width, uint16_t height, bool (*getTouch)(int16_t *, int16_t *))
: TouchScreenBase("touchscreen1", width, height), _getTouch(getTouch)
{
@@ -27,6 +51,7 @@ void TouchScreenImpl1::init()
if (portduino_config.touchscreenModule) {
TouchScreenBase::init(true);
inputBroker->registerSource(this);
attachTouchInterrupt();
} else {
TouchScreenBase::init(false);
}
@@ -37,9 +62,46 @@ void TouchScreenImpl1::init()
TouchScreenBase::init(true);
if (inputBroker)
inputBroker->registerSource(this);
attachTouchInterrupt();
#endif
#if defined(ENABLE_TOUCH_INT) && defined(ARCH_ESP32)
// Detach/reattach our interrupt around light sleep, so sleep.cpp can configure the touch
// pin as a wake source without our handler interfering.
lsObserver.observe(&notifyLightSleep);
lsEndObserver.observe(&notifyLightSleepEnd);
#endif
}
// Attach the touch-controller IRQ so a new touch wakes the polling thread immediately.
// No-op on boards without a usable touch interrupt line.
void TouchScreenImpl1::attachTouchInterrupt()
{
#ifdef ENABLE_TOUCH_INT
pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP);
attachInterrupt(SCREEN_TOUCH_INT, touchScreenInterruptHandler, FALLING);
LOG_INFO("TouchScreen interrupt attached on pin %d", SCREEN_TOUCH_INT);
#endif
}
#ifdef ARCH_ESP32
// Detach our interrupt before light sleep; sleep.cpp configures its own wake-on-touch.
int TouchScreenImpl1::beforeLightSleep(void *unused)
{
#ifdef ENABLE_TOUCH_INT
detachInterrupt(SCREEN_TOUCH_INT);
#endif
return 0; // Indicates success
}
// Reattach our interrupt after waking from light sleep.
int TouchScreenImpl1::afterLightSleep(esp_sleep_wakeup_cause_t cause)
{
attachTouchInterrupt();
return 0; // Indicates success
}
#endif
bool TouchScreenImpl1::getTouch(int16_t &x, int16_t &y)
{
return _getTouch(&x, &y);
+16
View File
@@ -13,7 +13,23 @@ class TouchScreenImpl1 : public TouchScreenBase
bool fastTapModeEnabled() const override;
bool longPressEnabled() const override;
// Attach/detach a hardware interrupt on the touch IRQ pin (SCREEN_TOUCH_INT) so a new touch
// wakes the polling thread immediately. No-op on boards without a usable touch interrupt line.
void attachTouchInterrupt();
bool (*_getTouch)(int16_t *, int16_t *);
#ifdef ARCH_ESP32
// Detach the touch interrupt before light sleep (so sleep.cpp can own the wake config),
// and reattach it afterwards. Mirrors ButtonThread's interrupt handling.
int beforeLightSleep(void *unused);
int afterLightSleep(esp_sleep_wakeup_cause_t cause);
CallbackObserver<TouchScreenImpl1, void *> lsObserver =
CallbackObserver<TouchScreenImpl1, void *>(this, &TouchScreenImpl1::beforeLightSleep);
CallbackObserver<TouchScreenImpl1, esp_sleep_wakeup_cause_t> lsEndObserver =
CallbackObserver<TouchScreenImpl1, esp_sleep_wakeup_cause_t>(this, &TouchScreenImpl1::afterLightSleep);
#endif
};
extern TouchScreenImpl1 *touchScreenImpl1;
+58 -21
View File
@@ -1,4 +1,7 @@
#include "configuration.h"
#ifdef ARCH_PORTDUINO_WASM
#include <emscripten.h>
#endif
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
#endif
@@ -97,7 +100,9 @@ NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr;
#ifdef ARCH_PORTDUINO
#include "linux/LinuxHardwareI2C.h"
#ifndef ARCH_PORTDUINO_WASM // raspi HTTP server (ulfius/zlib/openssl) excluded in the browser/wasm build
#include "mesh/raspihttp/PiWebServer.h"
#endif
#include "platform/portduino/PortduinoGlue.h"
#include <cstdlib>
#include <fstream>
@@ -395,7 +400,7 @@ void setup()
// M23 (audit): APPROTECT engagement moved below fsInit() so we can gate
// on EncryptedStorage::isProvisioned(). Engaging on an unprovisioned dev
// board permanently locks SWD before the operator has even set a
// passphrase a misconfigured CI build flashed to a developer device
// passphrase - a misconfigured CI build flashed to a developer device
// would brick its debug port on first boot. Now we only engage when the
// device has a DEK file on flash, i.e. the operator has explicitly
// committed to lockdown via passphrase provisioning.
@@ -431,7 +436,7 @@ void setup()
#endif
// The DEBUG_MUTE "we are muted, FYI" banner spills APP_VERSION / APP_ENV /
// APP_REPO out the USB CDC even with logging otherwise suppressed a free
// APP_REPO out the USB CDC even with logging otherwise suppressed - a free
// firmware-fingerprinting primitive for an attacker holding the cable.
// Under MESHTASTIC_LOCKDOWN we want the device to look uniformly silent
// until the operator authenticates, so skip the banner entirely there.
@@ -511,9 +516,9 @@ void setup()
EncryptedStorage::initLocked();
if (!EncryptedStorage::isUnlocked()) {
if (!EncryptedStorage::isProvisioned()) {
LOG_WARN("Lockdown: Device not provisioned connect and set a passphrase to unlock storage");
LOG_WARN("Lockdown: Device not provisioned - connect and set a passphrase to unlock storage");
} else {
LOG_WARN("Lockdown: Device locked connect and provide passphrase to unlock storage");
LOG_WARN("Lockdown: Device locked - connect and provide passphrase to unlock storage");
}
}
#endif
@@ -525,7 +530,7 @@ void setup()
// otherwise burn SWD on first boot before the operator has even set a
// passphrase, taking the board out of the dev/recovery workflow with
// no real security benefit (there's no DEK to protect yet). Once a
// DEK file exists, the operator has committed to lockdown engaging
// DEK file exists, the operator has committed to lockdown - engaging
// APPROTECT then is the protection they asked for.
if (EncryptedStorage::isProvisioned()) {
enableAPProtect();
@@ -792,6 +797,11 @@ void setup()
// We do this as early as possible because this loads preferences from flash
// but we need to do this after main cpu init (esp32setup), because we need the random seed set
nodeDB = new NodeDB;
#ifdef ARCH_ESP32
// Config is loaded now, and Bluetooth has not been initialized yet. If the
// saved config will keep Bluetooth inactive, return its reserved memory early.
esp32ReleaseBluetoothMemoryIfUnused();
#endif
// Initialize transmit history to persist broadcast throttle timers across reboots
TransmitHistory::getInstance()->loadFromDisk();
@@ -864,6 +874,17 @@ void setup()
delay(10);
#endif
drv.begin();
// Bits Field Value Meaning
// 7 N_ERM_LRA 1 LRA mode (vs 0 = ERM)
// 6:4 FB_BRAKE_FACTOR 3 4× brake factor
// 3:2 LOOP_GAIN 1 medium loop gain
// 1:0 BEMF_GAIN 2 back-EMF gain
#if defined(DRV2605_USE_LRA)
drv.writeRegister8(DRV2605_REG_FEEDBACK, 0xB6);
#endif
drv.selectLibrary(1);
// I2C trigger by sending 'go' command
drv.setMode(DRV2605_MODE_INTTRIG);
@@ -914,9 +935,7 @@ void setup()
#if HAS_SCREEN
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
#if defined(ST7701_CS) || defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \
defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) || \
defined(USE_SPISSD1306) || defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)
#if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306)
screen = new graphics::Screen(screen_found, screen_model, screen_geometry);
#elif defined(ARCH_PORTDUINO)
if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) &&
@@ -961,6 +980,12 @@ void setup()
gps = GPS::createGps();
if (gps) {
gpsStatus->observe(&gps->newStatus);
// If lora region is unset, disable the gps thread
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET &&
config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
gps->disable();
}
} else {
LOG_DEBUG("Run without GPS");
}
@@ -1040,9 +1065,7 @@ void setup()
#if !MESHTASTIC_EXCLUDE_I2C
// Don't call screen setup until after nodedb is setup (because we need
// the current region name)
#if defined(ST7701_CS) || defined(ST7735_CS) || defined(USE_EINK) || 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(USE_SPISSD1306) || defined(HACKADAY_COMMUNICATOR)
#if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306)
if (screen)
screen->setup();
#elif defined(ARCH_PORTDUINO)
@@ -1105,10 +1128,12 @@ void setup()
if (!rIf)
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO);
else {
#ifndef ARCH_PORTDUINO_WASM
// Log bit rate to debug output
LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) /
(float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) *
1000);
#endif
router->addInterface(std::move(rIf));
}
@@ -1136,8 +1161,8 @@ uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to
bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for OTA handoff)
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
volatile bool lockdownReloadPending; // see main.h deferred NodeDB reload after lockdown unlock
volatile bool lockdownDisablePending; // see main.h deferred decrypt-revert after lockdown disable
volatile bool lockdownReloadPending; // see main.h - deferred NodeDB reload after lockdown unlock
volatile bool lockdownDisablePending; // see main.h - deferred decrypt-revert after lockdown disable
#endif
// If a thread does something that might need for it to be rescheduled ASAP it can set this flag
@@ -1227,7 +1252,7 @@ void loop()
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
if (lockdownDisablePending) {
lockdownDisablePending = false;
LOG_INFO("Lockdown: disabling reverting encrypted storage to plaintext");
LOG_INFO("Lockdown: disabling - reverting encrypted storage to plaintext");
if (nodeDB->disableLockdownToPlaintext()) {
LOG_INFO("Lockdown: disabled, rebooting into normal mode");
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
@@ -1237,7 +1262,7 @@ void loop()
// The DEK file is still present (it's deleted last), so the device
// stays in lockdown and the operator can retry disable. Surface
// the failure rather than leaving the client hanging.
LOG_ERROR("Lockdown: disable revert failed device remains in lockdown");
LOG_ERROR("Lockdown: disable revert failed - device remains in lockdown");
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "disable_failed", 0, 0, 0);
}
}
@@ -1249,20 +1274,20 @@ void loop()
if (!reloadOk) {
// Storage decrypt/decode failed during reload. Treat as
// unrecoverable for this boot: lock storage, revoke any
// auth that managed to slip through (defense in depth the
// auth that managed to slip through (defense in depth - the
// cold-unlock path doesn't authorize until completion, but
// a concurrent re-verify-path call from another connection
// might have), and notify clients. Storage will be locked
// on next boot anyway; deferring to the user-visible
// notification path is sufficient for now.
LOG_ERROR("Lockdown: reload failed locking and notifying clients");
LOG_ERROR("Lockdown: reload failed - locking and notifying clients");
EncryptedStorage::lockNow();
PhoneAPI::revokeAllAuth();
}
PhoneAPI::completePendingUnlocks(reloadOk);
}
// Periodic session-expiry check. Cheap millis() comparison. Don't
// Periodic session-expiry check. Cheap - millis() comparison. Don't
// hammer it every loop tick; once a second is plenty.
static uint32_t lastSessionCheckMs = 0;
if (millis() - lastSessionCheckMs > 1000) {
@@ -1272,10 +1297,10 @@ void loop()
// 1. Budget remains (bootsRemaining > 0): decrement the
// on-flash boot count in place, revoke per-connection
// auth, re-engage screen redaction, re-arm the uptime
// timer all WITHOUT rebooting. Storage stays unlocked
// timer - all WITHOUT rebooting. Storage stays unlocked
// so the mesh keeps routing. Clients must re-authenticate
// to see content again. The decrement is what enforces
// the rollback ceiling bootsRemaining ticks down
// the rollback ceiling - bootsRemaining ticks down
// monotonically whether the device reboots or not.
// 2. Budget exhausted (bootsRemaining == 0): no more
// sessions to grant. Hard lock (token deleted, DEK
@@ -1311,6 +1336,9 @@ void loop()
#endif
#ifdef ARCH_NRF54L15
nrf54l15Loop();
#endif
#ifdef ARCH_RP2040
rp2040Loop();
#endif
power->powerCommandsCheck();
@@ -1321,7 +1349,7 @@ void loop()
RadioLibInterface::instance->pollMissedIrqs();
}
// Periodic AGC reset warm sleep + recalibrate to prevent stuck AGC gain
// Periodic AGC reset - warm sleep + recalibrate to prevent stuck AGC gain
static uint32_t lastAgcReset;
if (!Throttle::isWithinTimespanMs(lastAgcReset, AGC_RESET_INTERVAL_MS)) {
lastAgcReset = millis();
@@ -1395,7 +1423,16 @@ void loop()
#ifdef DEBUG_LOOP_TIMING
LOG_DEBUG("main loop delay: %d", delayMsec);
#endif
#ifdef ARCH_PORTDUINO_WASM
// Single-threaded wasm: mainDelay's InterruptableDelay is a pthread
// cond/mutex semaphore that no other thread can ever give(), and
// emscripten's single-threaded pthread_cond_timedwait busy-spins. Suspend
// cooperatively via Asyncify instead, capping idle sleep so the per-tick
// IRQ poll latency stays bounded (RX/TX-done is detected by polling).
emscripten_sleep(delayMsec > 50 ? 50 : delayMsec);
#else
mainDelay.delay(delayMsec);
#endif
}
}
#endif
+5 -2
View File
@@ -11,7 +11,7 @@
#include "mesh/generated/meshtastic/telemetry.pb.h"
#include <SPI.h>
#include <map>
#if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2)
#if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
#include "nimble/NimbleBluetooth.h"
extern NimbleBluetooth *nimbleBluetooth;
#endif
@@ -113,7 +113,10 @@ extern bool runASAP;
extern bool pauseBluetoothLogging;
void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), clearBonds(), enterDfuMode();
void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), rp2040Loop(), clearBonds(), enterDfuMode();
#ifdef ARCH_ESP32
void esp32ReleaseBluetoothMemoryIfUnused();
#endif
meshtastic_DeviceMetadata getDeviceMetadata();
#if !MESHTASTIC_EXCLUDE_I2C
+53
View File
@@ -0,0 +1,53 @@
// User-provided mbedTLS config - pulled in AFTER the default mbedtls_config.h
// via -DMBEDTLS_USER_CONFIG_FILE in the variant's platformio.ini.
//
// We compile mbedtls source files straight out of pico-sdk on bare metal, so
// every option that needs POSIX (time, sockets, filesystem) is disabled here.
// Without this, sources like net_sockets.c, timing.c, platform_util.c, etc.
// abort with #error or #include <sys/socket.h>.
//
// Code paths that touch these symbols are gated by the same MBEDTLS_* macros,
// so the linker simply drops the unreachable branches - no manual file
// exclusion in the build script.
#pragma once
// Entropy: entropy_poll.c does a hard `#error` on non-POSIX/non-Windows
// platforms. Tell it to skip the platform-specific entropy plumbing - our
// cert module passes a custom f_rng (picoRand → get_rand_64) directly into
// every mbedtls call that needs randomness, so we never invoke entropy_poll.
#define MBEDTLS_NO_PLATFORM_ENTROPY
// Time: pico-sdk mbedtls only knows clock_gettime() (POSIX) and GetTickCount64()
// (Win32). Neither exists here. We don't need calendar time on the server side
// (cert validity check at TLS init is the only user of MBEDTLS_HAVE_TIME_DATE
// and our self-signed cert is dated 2024-2034 so the client decides validity).
#undef MBEDTLS_HAVE_TIME
#undef MBEDTLS_HAVE_TIME_DATE
#undef MBEDTLS_TIMING_C
// Networking: net_sockets.c uses POSIX sockets. We wrap EthernetClient
// ourselves with mbedtls_ssl_set_bio() callbacks.
#undef MBEDTLS_NET_C
// Filesystem: cert/key load happens via our own LittleFS code, not via
// mbedtls_x509_crt_parse_file()/fopen().
#undef MBEDTLS_FS_IO
// PSA persistent storage: requires POSIX fopen. Unused.
#undef MBEDTLS_PSA_ITS_FILE_C
#undef MBEDTLS_PSA_CRYPTO_STORAGE_C
// Compile out TLS 1.3 entirely. pico-sdk's mbedtls_config defines
// MBEDTLS_SSL_PROTO_TLS1_3 but the server-side 1.3 plumbing in this
// vendored build is fragile: capping max_tls_version=TLS1_2 at runtime
// is enough for Firefox / openssl-3 (they downgrade cleanly), but
// Chrome's ClientHello carries TLS 1.3 extensions (post-quantum key
// shares, Encrypted ClientHello, etc.) that mbedtls tries to *parse*
// during the initial ClientHello processing before deciding to
// downgrade - and that parse crashes the board (no handshake state log
// ever fires, the crash is inside the first mbedtls_ssl_handshake()
// call). Removing the 1.3 code from the build sidesteps the parsers
// entirely; mbedtls will tell Chrome "TLS 1.2 only" via the
// ServerHello and ignore the 1.3 extensions.
#undef MBEDTLS_SSL_PROTO_TLS1_3
+54
View File
@@ -404,6 +404,60 @@ bool Channels::isDefaultChannel(ChannelIndex chIndex)
return false;
}
bool cryptoKeyIsPublic(const CryptoKey &key)
{
if (key.length == 0)
return true; // encryption disabled
// Match the defaultpsk family ignoring its last byte (getKey() bumps only that byte per 1-byte index).
if (key.length == (int)sizeof(defaultpsk) && memcmp(key.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0)
return true;
return false;
}
bool Channels::usesPublicKey(ChannelIndex chIndex)
{
const meshtastic_Channel &ch = getByIndex(chIndex);
if (!ch.has_settings || ch.role == meshtastic_Channel_Role_DISABLED)
return false;
const auto &psk = ch.settings.psk;
if (psk.size == 0) {
// Secondary channels inherit the primary key when unset; primary size==0 means encryption disabled.
if (ch.role == meshtastic_Channel_Role_SECONDARY) {
// Guard against malformed configs with no PRIMARY channel (primaryIndex could point back to us).
if (primaryIndex == chIndex)
return true; // fail closed: treat as public
return usesPublicKey(primaryIndex);
}
return true;
}
if (psk.size == 1) {
// Short PSK aliases: 0 disables encryption; 1..255 are the public defaultpsk family.
return true;
}
return (psk.size == sizeof(defaultpsk) && memcmp(psk.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0);
}
bool Channels::isWellKnownChannel(ChannelIndex chIndex)
{
const auto &ch = getByIndex(chIndex);
// Absent (unencrypted) or single-byte PSK - all the well-known key indexes
if (ch.settings.psk.size > 1)
return false;
const char *name = getName(chIndex);
for (int p = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; p <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; p++) {
const char *presetName =
DisplayFormatters::getModemPresetDisplayName(static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(p), false, true);
// Presets without a display name fall through to "Invalid" - never a match
if (strcmp(presetName, "Invalid") != 0 && strcmp(name, presetName) == 0)
return true;
}
return false;
}
bool Channels::hasDefaultChannel()
{
// If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel
+17 -5
View File
@@ -86,6 +86,15 @@ class Channels
// Returns true if the channel has the default name and PSK
bool isDefaultChannel(ChannelIndex chIndex);
// Returns true if this channel's effective key is publicly decryptable (open or well-known/default PSK).
bool usesPublicKey(ChannelIndex chIndex);
// Returns true if the channel is "well known": its PSK is absent or a
// single-byte well-known key index, AND its name is any modem-preset
// display name (e.g. a channel named "LongFast" counts even while the
// radio runs MediumFast). Broader than isDefaultChannel, which only
// matches the current preset's name and PSK byte 1.
bool isWellKnownChannel(ChannelIndex chIndex);
// Returns true if we can be reached via a channel with the default settings given a region and modem preset
bool hasDefaultChannel();
@@ -96,6 +105,11 @@ class Channels
bool setDefaultPresetCryptoForHash(ChannelHash channelHash);
/**
* Validate a channel, fixing any errors as needed
*/
meshtastic_Channel &fixupChannel(ChannelIndex chIndex);
int16_t getHash(ChannelIndex i) { return hashes[i]; }
private:
@@ -115,11 +129,6 @@ class Channels
*/
int16_t generateHash(ChannelIndex channelNum);
/**
* Validate a channel, fixing any errors as needed
*/
meshtastic_Channel &fixupChannel(ChannelIndex chIndex);
/**
* Writes the default lora config
*/
@@ -144,6 +153,9 @@ extern Channels channels;
static const uint8_t defaultpsk[] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01};
/// True if a getKey()-resolved key offers no privacy: length 0 (off) or the public defaultpsk family. Pure; for tests.
bool cryptoKeyIsPublic(const CryptoKey &key);
static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36,
0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74,
0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1};
+5 -2
View File
@@ -116,8 +116,11 @@ bool CryptoEngine::xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t po
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
if (sigLen == 0)
return false;
// the XEdDSA::sign function requires at least the first 32 bytes of signature to be pre-filled with randomness
HardwareRNG::fill(signature, 32);
// XEdDSA::sign mixes signature[0..31] into the nonce as the spec's random Z (meshtastic/Crypto#3)
// for hedged signatures, so seed it - hardware RNG, else the seeded CSPRNG. A weak Z is still
// safe against nonce reuse (defense-in-depth only), so we never fail signing over it.
if (!HardwareRNG::fill(signature, 32))
CryptRNG.rand(signature, 32);
XEdDSA::sign(signature, xeddsa_private_key, xeddsa_public_key, sigBuf, sigLen);
return true;
}
+3
View File
@@ -24,6 +24,9 @@ struct CryptoKey {
#define MAX_BLOCKSIZE 256
#define TEST_CURVE25519_FIELD_OPS // Exposes Curve25519::isWeakPoint() for testing keys
#define XEDDSA_SIGNATURE_SIZE 64
// Encoded size the signature adds to the Data protobuf: 1 tag byte (field 10 < 16) +
// 1 length byte (64 < 128) + 64 signature bytes. test_packet_signing asserts this stays exact.
#define XEDDSA_SIGNATURE_FIELD_BYTES (XEDDSA_SIGNATURE_SIZE + 2)
class CryptoEngine
{
+1 -1
View File
@@ -71,7 +71,7 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d
int8_t throttle =
(type == TrafficType::POSITION) ? myRegion->profile->positionThrottle : myRegion->profile->telemetryThrottle;
// throttle <= 0 means unset; 1 is the neutral multiplier skip the multiply for performance
// throttle <= 0 means unset; 1 is the neutral multiplier - skip the multiply for performance
if (throttle <= 1)
return baseMs;
+12 -2
View File
@@ -18,6 +18,9 @@
#define default_telemetry_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define default_broadcast_smart_minimum_interval_secs 5 * 60
// Floor for our own position broadcasts when stationary (unchanged beyond the broadcast
// precision) or fixed_position: identical positions get deduped by traffic management anyway.
#define default_position_stationary_broadcast_secs (12 * 60 * 60)
#define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define min_default_broadcast_smart_minimum_interval_secs 5 * 60
#define default_wait_bluetooth_secs IF_ROUTER(1, 60)
@@ -27,6 +30,7 @@
#define default_screen_on_secs IF_ROUTER(1, 60 * 10)
#define default_node_info_broadcast_secs 3 * 60 * 60
#define default_neighbor_info_broadcast_secs 6 * 60 * 60
#define default_mesh_beacon_min_broadcast_interval_secs 3600
#define min_node_info_broadcast_secs 60 * 60 // No regular broadcasts of more than once an hour
#define min_neighbor_info_broadcast_secs 4 * 60 * 60
#define default_map_publish_interval_secs 60 * 60
@@ -34,8 +38,14 @@
enum class TrafficType { POSITION, TELEMETRY };
// Traffic management defaults
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m)
#define default_traffic_mgmt_position_min_interval_secs (11 * 60 * 60) // 11 hours between identical positions
// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 11h default).
#define default_traffic_mgmt_tracker_position_min_interval_secs (60 * 60) // 1 hour
// Role cap: lost-and-found origins may refresh a duplicate position this often, so a lost
// device updates frequently without flooding. (Quantised to the dedup tick: ~2 ticks.)
// Unlike before, lost-and-found is NOT exempt from the relayed precision clamp.
#define default_traffic_mgmt_lost_and_found_position_min_interval_secs (15 * 60) // 15 minutes
// Hop scaling defaults
#define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count
+3
View File
@@ -128,6 +128,9 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy)
if (generated == static_cast<ssize_t>(length)) {
filled = true;
}
#elif defined(__EMSCRIPTEN__)
// Browser/wasm: no getrandom/arc4random - fall through to std::random_device,
// which emscripten backs with crypto.getRandomValues().
#else
// arc4random_buf is available on Darwin/BSD and cannot fail.
::arc4random_buf(buffer, length);
+2
View File
@@ -8,8 +8,10 @@
#include "SX126xInterface.h"
#include "SX128xInterface.cpp"
#include "SX128xInterface.h"
#ifndef ARCH_PORTDUINO_WASM // TCP socket API server excluded in the browser/wasm build
#include "api/ServerAPI.cpp"
#include "api/ServerAPI.h"
#endif
// We need this declaration for proper linking in derived classes
#if RADIOLIB_EXCLUDE_SX126X != 1
+6 -2
View File
@@ -205,7 +205,7 @@ template <typename T> bool LR11x0Interface<T>::reconfigure()
err = lora.setOutputPower(power);
assert(err == RADIOLIB_ERR_NONE);
// Apply RX gain mode valid in STDBY, matches resetAGC() pattern
// Apply RX gain mode - valid in STDBY, matches resetAGC() pattern
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
if (err != RADIOLIB_ERR_NONE)
LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err);
@@ -326,7 +326,7 @@ template <typename T> void LR11x0Interface<T>::resetAGC()
LOG_DEBUG("LR11x0 AGC reset: warm sleep + Calibrate(0x3F)");
// 1. Warm sleep powers down the analog frontend, resetting AGC state
// 1. Warm sleep - powers down the analog frontend, resetting AGC state
lora.sleep(true, 0);
// 2. Wake to RC standby for stable calibration
@@ -371,7 +371,11 @@ template <typename T> bool LR11x0Interface<T>::sleep()
template <typename T> int16_t LR11x0Interface<T>::getCurrentRSSI()
{
#ifdef ARCH_PORTDUINO_WASM
float rssi = lora.getRSSI(); // installed RadioLib's LR11x0 getRSSI() is 0-arg
#else
float rssi = lora.getRSSI(false, true);
#endif
return (int16_t)round(rssi);
}
#endif
+2 -2
View File
@@ -211,7 +211,7 @@ template <typename T> bool LR20x0Interface<T>::reconfigure()
err = lora.setOutputPower(power);
assert(err == RADIOLIB_ERR_NONE);
// Apply RX gain mode valid in STDBY, matches resetAGC() pattern
// Apply RX gain mode - valid in STDBY, matches resetAGC() pattern
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
if (err != RADIOLIB_ERR_NONE)
LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err);
@@ -332,7 +332,7 @@ template <typename T> void LR20x0Interface<T>::resetAGC()
LOG_DEBUG("LR20x0 AGC reset: warm sleep + Calibrate(0x3F)");
// 1. Warm sleep powers down the analog frontend, resetting AGC state
// 1. Warm sleep - powers down the analog frontend, resetting AGC state
lora.sleep(true, 0);
// 2. Wake to RC standby for stable calibration
+29
View File
@@ -57,6 +57,11 @@ static void releaseSleepHolds()
void LoRaFEMInterface::init(void)
{
setLnaCanControl(false); // Default is uncontrollable
#if defined(RF_PA_DETECT_PIN)
pinMode(RF_PA_DETECT_PIN, INPUT);
high_power_pa = (digitalRead(RF_PA_DETECT_PIN) == RF_PA_HIGH_POWER_VALUE);
LOG_INFO("Detected %s LoRa PA profile", high_power_pa ? "high-power" : "low-power");
#endif
#ifdef HELTEC_V4
pinMode(LORA_PA_POWER, OUTPUT);
digitalWrite(LORA_PA_POWER, HIGH);
@@ -119,6 +124,13 @@ void LoRaFEMInterface::init(void)
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
setLnaCanControl(true);
#elif defined(USE_KCT8103L_PA_ONLY)
fem_type = KCT8103L_PA;
pinMode(LORA_KCT8103L_EN, OUTPUT);
digitalWrite(LORA_KCT8103L_EN, HIGH);
delay(1);
pinMode(LORA_KCT8103L_TX_RX, OUTPUT);
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
#endif
}
@@ -148,6 +160,9 @@ void LoRaFEMInterface::setSleepModeEnable(void)
// shutdown the PA
digitalWrite(LORA_KCT8103L_PA_CSD, LOW);
digitalWrite(LORA_PA_POWER, LOW);
#elif defined(USE_KCT8103L_PA_ONLY)
// shutdown the PA
digitalWrite(LORA_KCT8103L_EN, LOW);
#endif
}
@@ -173,6 +188,9 @@ void LoRaFEMInterface::setTxModeEnable(void)
enableFEMPower();
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
#elif defined(USE_KCT8103L_PA_ONLY)
enableFEMPower();
digitalWrite(LORA_KCT8103L_TX_RX, HIGH);
#endif
}
@@ -206,6 +224,9 @@ void LoRaFEMInterface::setRxModeEnable(void)
} else {
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
}
#elif defined(USE_KCT8103L_PA_ONLY)
enableFEMPower();
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
#endif
}
@@ -247,6 +268,9 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
#endif
#elif defined(USE_KCT8103L_PA_ONLY)
enableFEMPower();
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
#endif
}
@@ -257,6 +281,11 @@ void LoRaFEMInterface::setLNAEnable(bool enabled)
int8_t LoRaFEMInterface::powerConversion(int8_t loraOutputPower)
{
#if defined(RF_PA_DETECT_PIN)
if (!high_power_pa) {
return loraOutputPower;
}
#endif
#ifdef HELTEC_V4
const uint16_t gc1109_tx_gain[] = {11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 9, 9, 8, 7};
const uint16_t kct8103l_tx_gain[] = {13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7};
+2 -1
View File
@@ -24,7 +24,8 @@ class LoRaFEMInterface
LoRaFEMType fem_type;
bool lna_enabled = true;
bool lna_can_control = false;
bool high_power_pa = true;
};
extern LoRaFEMInterface loraFEMInterface;
#endif
#endif
+1 -1
View File
@@ -68,7 +68,7 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod
p->decoded.request_id = idFrom;
p->channel = chIndex;
if (err != meshtastic_Routing_Error_NONE)
LOG_WARN("Alloc an err=%d,to=0x%x,idFrom=0x%x,id=0x%x", err, to, idFrom, p->id);
LOG_WARN("Alloc an err=%d,to=0x%08x,idFrom=0x%08x,id=0x%08x", err, to, idFrom, p->id);
return p;
}
+5
View File
@@ -88,6 +88,11 @@ extern const RegionInfo *myRegion;
extern void initRegion();
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
// Fill `map` with the region->valid-preset table, grouped so regions sharing a
// preset list reference the same group. Sent to clients during want_config so
// their UI can block illegal region+preset combinations.
extern void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map);
// Valid LoRa spread factor range and defaults
constexpr uint8_t LORA_SF_MIN = 5;
constexpr uint8_t LORA_SF_MAX = 12;
+31 -2
View File
@@ -288,24 +288,53 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies)
LOG_DEBUG("Skip position ping; no fresh position since boot");
return false;
}
LOG_INFO("Send position ping to 0x%x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel);
LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel);
positionModule->sendOurPosition(dest, wantReplies, node->channel);
return true;
}
} else {
#endif
if (nodeInfoModule) {
LOG_INFO("Send nodeinfo ping to 0x%x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel);
LOG_INFO("Send nodeinfo ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel);
nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel);
}
}
return false;
}
// Re-decode nested string-bearing payloads before local phone delivery so PB_VALIDATE_UTF8 rejects
// malformed NodeInfo/Waypoint data a strict phone decoder could crash on. Mesh relay is unaffected.
bool MeshService::phonePayloadIsDecodable(const meshtastic_Data &d)
{
// User/Waypoint are all-static nanopb messages (no PB_ENABLE_MALLOC/callback fields), so the
// decoded scratch owns no heap and needs no pb_release.
switch (d.portnum) {
case meshtastic_PortNum_NODEINFO_APP: {
meshtastic_User u = meshtastic_User_init_zero;
return pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_User_msg, &u);
}
case meshtastic_PortNum_WAYPOINT_APP: {
meshtastic_Waypoint w = meshtastic_Waypoint_init_zero;
return pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_Waypoint_msg, &w);
}
default:
return true;
}
}
void MeshService::sendToPhone(meshtastic_MeshPacket *p)
{
perhapsDecode(p);
// Withhold decoded nested payloads a strict phone decoder would reject; still-encrypted packets
// pass through (the phone may hold the key).
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && !phonePayloadIsDecodable(p->decoded)) {
LOG_WARN("Dropping undecodable portnum=%d payload from phone delivery (from=0x%08x)", p->decoded.portnum, p->from);
releaseToPool(p);
fromNum++; // notify observers so the phone can resync
return;
}
#ifdef ARCH_ESP32
#if !MESHTASTIC_EXCLUDE_STOREFORWARD
if (moduleConfig.store_forward.enabled && storeForwardModule->isServer() &&
+5
View File
@@ -100,6 +100,11 @@ class MeshService
p->decoded.portnum == meshtastic_PortNum_DETECTION_SENSOR_APP ||
p->decoded.portnum == meshtastic_PortNum_ALERT_APP;
}
/// Returns false when a decoded NodeInfo/Waypoint payload fails nested protobuf decode (invalid
/// UTF-8 under PB_VALIDATE_UTF8, etc.); other portnums pass through. Callers gate on the variant.
static bool phonePayloadIsDecodable(const meshtastic_Data &decoded);
/// Called when some new packets have arrived from one of the radios
Observable<uint32_t> fromNumChanged;
+5
View File
@@ -45,6 +45,11 @@ enum RxSource {
// For old firmware there is no relay node set
#define NO_RELAY_NODE 0
// How recently we must have heard a direct neighbor for its single-byte relay id to be trusted as a
// unique next hop. Mirrors NUM_ONLINE_SECS (NodeDB.cpp). Used by NodeDB::resolveLastByte() to scope
// last-byte collision resolution to currently-reachable neighbors.
#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2) // 2 hrs
typedef int ErrorCode;
/// Alloc and free packets to our global, ISR safe pool
+209 -20
View File
@@ -98,21 +98,38 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
// destination
if (p->from != 0) {
meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from);
if (origTx) {
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination
// Single lookup for both relayer checks on the same (request_id, to) pair
bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
bool weWereRelayer = false;
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
&weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
if (origTx->next_hop != p->relay_node) { // Not already set
LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination. checkRelayers is read-only on PacketHistory and O(1), so we run it even
// when origTx is absent - that lets us still capture the confirmed hop into the TMM overflow cache below.
// Single lookup for both relayer checks on the same (request_id, to) pair
bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
bool weWereRelayer = false;
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
&weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
// M1/M2: only learn a next hop whose last byte maps to a single plausible relay. On a dense
// mesh the byte may be ambiguous; storing it would aim future DMs at the wrong node. This gate
// now protects BOTH the hot-store route (NodeInfoLite.next_hop) AND the TMM overflow cache -
// the overflow cache deliberately holds many more next-hop bytes (long-tail nodes), so it is
// even more collision-prone and must never store an ambiguous byte either. Ambiguous/unknown
// -> store nothing and keep flooding (safe).
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) {
if (origTx && origTx->next_hop != p->relay_node) { // Not already set
LOG_INFO("Update next hop of 0x%08x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
p->relay_node, wasAlreadyRelayer, weWereSoleRelayer);
origTx->next_hop = p->relay_node;
}
noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route)
#if HAS_TRAFFIC_MANAGEMENT
// Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it
// survives even when the source isn't (or is no longer) in the hot NodeDB.
if (trafficManagementModule)
trafficManagementModule->setNextHop(p->from, p->relay_node);
#endif
} else {
LOG_DEBUG("Not learning next hop for 0x%08x: relay byte 0x%x ambiguous/unknown; keep flooding", p->from,
p->relay_node);
}
}
}
@@ -144,6 +161,11 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
if (p->id != 0) {
if (isRebroadcaster()) {
// NOTE: this is a self-identity match (is the addressed next_hop OUR last byte?), so it
// cannot be hardened with resolveLastByte() - a remote node that legitimately shares our
// last byte will also match here and rebroadcast. That residual collision needs a wider
// on-wire field to fix. M1/M2 instead shrink the blast radius by reducing how often an
// ambiguous next_hop byte is ever learned (sniffReceived) or originated (getNextHop).
if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
LOG_INFO("Rebroadcast received message coming from %x", p->relay_node);
@@ -194,15 +216,63 @@ std::optional<uint8_t> NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
if (isBroadcast(to))
return std::nullopt;
// Hot store first: a direct array hit on the live NodeDB entry.
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
if (node && node->next_hop) {
// M3: proactively decay a stale or repeatedly-failing route back to flooding, so a dead hop
// isn't trusted on the next DM's first (and on dense meshes, slowest) attempt. We only act on
// a health record that still matches the stored byte; a next_hop set by another path (e.g.
// TraceRouteModule) with no matching record is left authoritative.
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) {
LOG_INFO("Next hop 0x%x for 0x%08x is stale (age/fails); flood and clear", node->next_hop, to);
node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
// We are careful not to return the relay node as the next hop
if (node->next_hop != relay_node) {
// LOG_DEBUG("Next hop for 0x%x is 0x%x", to, node->next_hop);
return node->next_hop;
// M1/M2: only emit a stored next_hop if its last byte still maps to a UNIQUE, currently
// reachable direct neighbor. On a dense mesh the last byte collides, so an ambiguous byte
// would unicast a hint toward the wrong physical node; if the neighbor has gone away we'd
// unicast into a void. In both cases flood instead (managed flooding still delivers).
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique)
return node->next_hop;
LOG_WARN("Next hop 0x%x for 0x%08x %s; set no pref", node->next_hop, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
} else
LOG_WARN("Next hop for 0x%x is 0x%x, same as relayer; set no pref", to, node->next_hop);
LOG_WARN("Next hop for 0x%08x is 0x%x, same as relayer; set no pref", to, node->next_hop);
}
#if HAS_TRAFFIC_MANAGEMENT
// Fallback: TMM overflow cache holds confirmed hops for nodes that have aged out of the hot store.
// It is the same byte source/confidence as NodeInfoLite.next_hop, so it gets the same M1/M2/M3
// protection: decay a stale/failing route, then only emit a byte that still resolves to a unique
// reachable neighbor. Without this the overflow cache (which holds MORE bytes for MORE nodes) would
// reintroduce exactly the silent-misroute that M1/M2 closes on the hot path.
if (trafficManagementModule) {
uint8_t hint = trafficManagementModule->getNextHopHint(to);
if (hint && hint != relay_node) {
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) {
LOG_INFO("TMM next hop 0x%x for 0x%08x is stale (age/fails); flood and clear", hint, to);
trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0)
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique) {
LOG_DEBUG("Next hop for 0x%08x is 0x%x (TMM cache)", to, hint);
return hint;
}
LOG_WARN("TMM next hop 0x%x for 0x%08x %s; set no pref", hint, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
}
}
#endif
return std::nullopt;
}
@@ -298,30 +368,56 @@ int32_t NextHopRouter::doRetransmissions()
if (p.nextTxMsec <= now) {
if (p.numRetransmissions == 0) {
if (isFromUs(p.packet)) {
LOG_DEBUG("Reliable send failed, returning a nak for fr=0x%x,to=0x%x,id=0x%x", p.packet->from, p.packet->to,
p.packet->id);
LOG_DEBUG("Reliable send failed, returning a nak for fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from,
p.packet->to, p.packet->id);
sendAckNak(meshtastic_Routing_Error_MAX_RETRANSMIT, getFrom(p.packet), p.packet->id, p.packet->channel);
}
// Note: we don't stop retransmission here, instead the Nak packet gets processed in sniffReceived
stopRetransmission(it->first);
stillValid = false; // just deleted it
} else {
LOG_DEBUG("Sending retransmission fr=0x%x,to=0x%x,id=0x%x, tries left=%d", p.packet->from, p.packet->to,
LOG_DEBUG("Sending retransmission fr=0x%08x,to=0x%08x,id=0x%08x, tries left=%d", p.packet->from, p.packet->to,
p.packet->id, p.numRetransmissions);
if (!isBroadcast(p.packet->to)) {
if (p.numRetransmissions == 1) {
// Last retransmission, reset next_hop (fallback to FloodingRouter)
// Last retransmission: this directed delivery went un-ACKed. Record the failure
// (M3 - accumulates across DMs to age out a flapping/dead route) and reset
// next_hop so the final try falls back to FloodingRouter.
noteRouteFailure(p.packet->to);
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
// Also reset it in the nodeDB
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
if (sentTo) {
LOG_INFO("Resetting next hop for packet with dest 0x%x\n", p.packet->to);
LOG_INFO("Resetting next hop for packet with dest 0x%08x", p.packet->to);
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
}
#if HAS_TRAFFIC_MANAGEMENT
if (trafficManagementModule) {
trafficManagementModule->clearNextHop(p.packet->to);
}
#endif
FloodingRouter::send(packetPool.allocCopy(*p.packet));
} else {
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
// M4 (gated): if the route isn't proven healthy, don't spend a second directed
// attempt - start flooding one retry sooner to cut recovery latency. A verified
// route (fresh, zero recent failures) keeps the unchanged directed-retry path so
// the sparse-mesh happy path is untouched.
RouteHealth *h = findRouteHealth(p.packet->to);
bool verified = h && h->consecutiveFailures == 0 && !isRouteStale(*h, now);
if (!verified) {
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
if (sentTo)
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
FloodingRouter::send(packetPool.allocCopy(*p.packet));
} else {
NextHopRouter::send(packetPool.allocCopy(*p.packet));
}
#else
NextHopRouter::send(packetPool.allocCopy(*p.packet));
#endif
}
} else {
// Note: we call the superclass version because we don't want to have our version of send() add a new
@@ -355,3 +451,96 @@ void NextHopRouter::setNextTx(PendingPacket *pending)
printPacket("", pending->packet);
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
}
// ---------------------------------------------------------------------------
// M3: RAM route-health table. Bounded array with reuse-oldest eviction (same discipline as
// PacketHistory). All age comparisons use unsigned subtraction so they survive the 49.7-day millis()
// rollover. dest == 0 marks an empty slot; learnedAtMsec is normalized to 1 on write so an occupied
// slot is never read as infinitely old.
// ---------------------------------------------------------------------------
RouteHealth *NextHopRouter::findRouteHealth(NodeNum dest)
{
if (dest == 0)
return nullptr;
for (auto &h : routeHealth)
if (h.dest == dest)
return &h;
return nullptr;
}
RouteHealth *NextHopRouter::getOrAllocRouteHealth(NodeNum dest, uint32_t now)
{
if (dest == 0)
return nullptr;
RouteHealth *oldest = &routeHealth[0];
RouteHealth *freeSlot = nullptr;
for (auto &h : routeHealth) {
if (h.dest == dest)
return &h; // existing record
if (h.dest == 0) {
if (!freeSlot)
freeSlot = &h; // remember the first free slot; prefer it over evicting
continue;
}
// Track the oldest occupied slot in case the table is full (rollover-safe).
if ((uint32_t)(now - h.learnedAtMsec) > (uint32_t)(now - oldest->learnedAtMsec))
oldest = &h;
}
// Claim the free slot if there is one, else reuse the oldest. Reset before use and stamp the dest
// so the record is findable.
RouteHealth *slot = freeSlot ? freeSlot : oldest;
*slot = RouteHealth{};
slot->dest = dest;
return slot;
}
void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now)
{
if (dest == 0 || nextHop == NO_NEXT_HOP_PREFERENCE)
return;
RouteHealth *h = getOrAllocRouteHealth(dest, now);
if (!h)
return;
// A genuinely new next hop earns a clean slate; re-learning the SAME hop keeps the accumulated
// failure count so an asymmetric reverse path that keeps re-teaching a dead forward hop still ages
// out instead of resetting the counter every time.
if (h->lastNextHop != nextHop) {
h->lastNextHop = nextHop;
h->consecutiveFailures = 0;
}
h->learnedAtMsec = now ? now : 1;
}
void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // only routes we actually learned have health to refresh
h->consecutiveFailures = 0;
h->learnedAtMsec = now ? now : 1;
}
void NextHopRouter::noteRouteFailure(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // nothing to penalize (we were flooding, or never learned a route here)
if (h->consecutiveFailures < 255)
h->consecutiveFailures++;
}
bool NextHopRouter::isRouteStale(const RouteHealth &h, uint32_t now) const
{
if (h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD)
return true;
return (uint32_t)(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC;
}
void NextHopRouter::clearRouteHealth(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (h)
*h = RouteHealth{};
}
+57
View File
@@ -43,6 +43,28 @@ struct PendingPacket {
explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions);
};
/**
* RAM-only per-destination route health. Tracks how fresh a learned next_hop is and how many
* consecutive directed deliveries to it have failed, so getNextHop() can proactively decay a stale or
* repeatedly-failing route back to flooding instead of trusting a dead hop on the next (and on dense
* meshes, slowest) attempt. Not persisted: the learned next_hop itself lives in NodeInfoLite; this is
* just freshness/failure metadata.
*/
struct RouteHealth {
NodeNum dest = 0; ///< destination this record describes; 0 == empty slot
uint32_t learnedAtMsec = 0; ///< millis() when next_hop was last (re)learned (rollover-aware)
uint8_t consecutiveFailures = 0; ///< directed deliveries to `dest` that went un-ACKed
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; ///< the relay byte this health refers to
};
// M4 (optional, off by default): when a route is not proven healthy, fall back to flooding one retry
// earlier instead of spending a second directed attempt. Trades airtime for recovery latency on dense
// meshes; leaves the sparse-mesh happy path (fresh, verified routes) unchanged. Measure on the
// simulator before enabling broadly.
#ifndef NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 0
#endif
class GlobalPacketIdHashFunction
{
public:
@@ -92,12 +114,22 @@ class NextHopRouter : public FloodingRouter
// The number of retransmissions the original sender will do
constexpr static uint8_t NUM_RELIABLE_RETX = 3;
// M3: bounded RAM route-health table (reuse-oldest eviction, like PacketHistory)
constexpr static uint8_t ROUTE_HEALTH_MAX = 32; // ~12B/slot -> ~384B
constexpr static uint32_t ROUTE_TTL_MSEC = 30UL * 60 * 1000; // re-discover a route unconfirmed for 30 min
constexpr static uint8_t ROUTE_FAILURE_THRESHOLD = 3; // consecutive un-ACKed directed deliveries -> dead
protected:
/**
* Pending retransmissions
*/
std::unordered_map<GlobalPacketId, PendingPacket, GlobalPacketIdHashFunction> pending;
/**
* Per-destination route health (M3). Bounded array, reuse-oldest eviction. RAM-only.
*/
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
/**
* Should this incoming filter be dropped?
*
@@ -142,13 +174,38 @@ class NextHopRouter : public FloodingRouter
void setNextTx(PendingPacket *pending);
// --- M3 route-health helpers (RAM-only). Protected so ReliableRouter (a subclass) can record
// delivery success, and so the unit-test shim can reach them via `using`. All take `now` where
// time matters so the decay logic is pure and testable without a clock mock. ---
/// @return the health record for `dest`, or nullptr if we hold none.
RouteHealth *findRouteHealth(NodeNum dest);
/// @return an existing record for `dest`, else a freshly claimed slot (reuse-oldest on overflow).
RouteHealth *getOrAllocRouteHealth(NodeNum dest, uint32_t now);
/// Record that we (re)learned `nextHop` for `dest`. Resets the failure count only when the hop
/// changed (so a flapping reverse-path re-learn of the same dead hop still ages out).
void noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now);
/// Record an end-to-end delivery success to `dest` (clears failures, refreshes freshness).
void noteRouteSuccess(NodeNum dest, uint32_t now);
/// Record that a directed delivery to `dest` went un-ACKed (no-op if we hold no record).
void noteRouteFailure(NodeNum dest);
/// @return true if the route is too old (TTL) or has failed too many times in a row.
bool isRouteStale(const RouteHealth &h, uint32_t now) const;
/// Forget any health record for `dest`.
void clearRouteHealth(NodeNum dest);
#ifdef PIO_UNIT_TESTING
public: // expose getNextHop to the test shim without widening production visibility
#else
private:
#endif
/**
* Get the next hop for a destination, given the relay node
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
*/
std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node);
private:
/** Check if we should be rebroadcasting this packet if so, do so.
* @return true if we did rebroadcast */
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
+833 -84
View File
File diff suppressed because it is too large Load Diff
+120 -12
View File
@@ -11,6 +11,7 @@
#include "MeshTypes.h"
#include "NodeStatus.h"
#include "WarmNodeStore.h"
#include "concurrency/Lock.h"
#include "configuration.h"
#include "mesh-pb-constants.h"
@@ -114,6 +115,20 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
/// Given a packet, return how many seconds in the past (vs now) it was received
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.
/// Because the wire only carries the last byte of a 32-bit node number, the mapping is ambiguous on
/// dense meshes (the "birthday problem"). Callers must treat Ambiguous and None as "don't trust it".
enum class LastByteResolution : uint8_t {
None, ///< no relevant candidate node has this last byte
Unique, ///< exactly one relevant candidate -> `num` is valid
Ambiguous, ///< two or more relevant candidates collide on this byte
};
struct ResolvedNode {
LastByteResolution status = LastByteResolution::None;
NodeNum num = 0; ///< valid only when status == Unique
};
/// Given a packet, return the number of hops used to reach this node.
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
@@ -226,9 +241,25 @@ class NodeDB
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0);
/*
* Sets a node either favorite or unfavorite
* Sets a node either favorite or unfavorite. Returns true if the node ends
* up in the requested state; false if the node is unknown or favouriting
* was refused by the protected-node cap (MAX_NUM_NODES - 2).
*/
void set_favorite(bool is_favorite, uint32_t nodeId);
bool set_favorite(bool is_favorite, uint32_t nodeId);
/// Count of eviction-protected (favourite/ignored/manually-verified) nodes.
int numProtectedNodes() const;
/// printf-style warning emitted when setProtectedFlag() refuses a node at
/// the cap. %s = verb (favorite/ignore), 0x%08x = node, %d = cap. Shared by
/// LOG_WARN here and AdminModule::sendWarning so the wording stays in sync.
static constexpr const char *PROTECTED_CAP_WARN_FMT = "Can't %s 0x%08x: protected-node limit (%d) reached";
/// Turn an eviction-protection flag (favourite/ignored/verified) on/off. Off
/// always succeeds; on returns false (no change) once the protected set hits
/// the cap (MAX_NUM_NODES-2), keeping >=2 always-evictable slots. Callers
/// surface the refusal to the user.
bool setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on);
/*
* Returns true if the node is in the NodeDB and marked as favorite
@@ -295,6 +326,46 @@ class NodeDB
virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n);
size_t getNumMeshNodes() { return numMeshNodes; }
/// Find a node in our DB, create an empty NodeInfoLite if missing (evicting
/// the oldest non-protected node when full). Public so admin handlers can
/// register a node we have not heard from yet (e.g. to block it by ID).
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
#if WARM_NODE_COUNT > 0
// Warm ("long-tail") tier: minimal {num, last_heard, public_key} records
// for nodes evicted from the hot store. See WarmNodeStore.h.
WarmNodeStore warmStore;
#endif
/// Copy the 32-byte public key for node n - hot store first, then the warm
/// tier. Returns false if we don't know a key for n.
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// Resolve a node's device role - hot store (with user) first, then the role
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
/// nodes that have aged out of the hot store.
meshtastic_Config_DeviceConfig_Role getNodeRole(NodeNum n);
/// last_heard of a hot-store node, or 0 if absent. Plain scan of meshNodes
/// with no allocation side effects (unlike getOrCreateMeshNode).
uint32_t hotNodeLastHeard(NodeNum n) const;
/**
* Resolve a single on-wire last-byte (e.g. next_hop / relay_node) back to a unique full NodeNum,
* detecting last-byte collisions instead of silently picking the first match. A 1-byte id only
* needs to be unique among a node's plausible relays, not the whole mesh, so we scope the search:
* - requireDirectNeighbor == true : candidates are direct neighbors (hops_away==0) heard within
* NEXTHOP_NEIGHBOR_FRESH_SECS. Use on the SEND path.
* - requireDirectNeighbor == false : also accept favorites and router-role nodes (unknown hop
* distance allowed). Use when learning / preserving hops.
* Ignored nodes, our own node, and the broadcast/0 sentinels are never candidates. On a tie the
* result is Ambiguous (no tie-break) so callers fall back to flooding rather than misroute.
*/
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
/// Convenience wrapper around resolveLastByte(): true iff exactly one relevant candidate matches.
/// Ambiguous and None both return false (the safe answer for learning / hop preservation).
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
// Thread-safe satellite-map accessors. Return false if absent or the
// corresponding DB is compiled out.
@@ -341,11 +412,15 @@ class NodeDB
emptyNodeDatabase.version = DEVICESTATE_CUR_VER;
size_t nodeDatabaseSize;
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
// Always include satellite slots so backups from higher-cap peers
// decode without truncation, even when our build excludes the DBs.
return nodeDatabaseSize + (MAX_NUM_NODES * meshtastic_NodeInfoLite_size) +
(MAX_NUM_NODES * meshtastic_NodePositionEntry_size) + (MAX_NUM_NODES * meshtastic_NodeTelemetryEntry_size) +
(MAX_NUM_NODES * meshtastic_NodeEnvironmentEntry_size) + (MAX_NUM_NODES * meshtastic_NodeStatusEntry_size);
// Decode-stream size ceiling only - no buffer this big is allocated (load
// streams from the file). Sized for the largest file any prior firmware
// could write (250-node ESP32-S3, satellites uncapped) so capacity
// downgrades / peer backups still decode; excess is trimmed after load.
// (not constexpr: portduino resolves MAX_NUM_NODES from runtime config)
const size_t loadCeiling = ((size_t)MAX_NUM_NODES > 250) ? (size_t)MAX_NUM_NODES : 250;
return nodeDatabaseSize + (loadCeiling * meshtastic_NodeInfoLite_size) +
(loadCeiling * meshtastic_NodePositionEntry_size) + (loadCeiling * meshtastic_NodeTelemetryEntry_size) +
(loadCeiling * meshtastic_NodeEnvironmentEntry_size) + (loadCeiling * meshtastic_NodeStatusEntry_size);
}
// returns true if the maximum number of nodes is reached or we are running low on memory
@@ -405,7 +480,7 @@ class NodeDB
/// Returns true iff every encrypted file decrypted and decoded cleanly.
/// On false the caller MUST treat the storage as corrupt: leave the
/// connection unauthenticated, emit a LOCKED(storage_corrupt) status,
/// and refuse to call setAdminAuthorized otherwise a subsequent
/// and refuse to call setAdminAuthorized - otherwise a subsequent
/// set_config would re-encrypt a wrong baseline (the locked-default
/// values still resident in `config` / `channelFile` / `nodeDatabase`)
/// and overwrite the operator's persisted state.
@@ -414,7 +489,7 @@ class NodeDB
/// Disable lockdown: decrypt every encrypted pref file back to plaintext,
/// then remove the DEK / token / counter / backoff artifacts. Requires
/// EncryptedStorage to be unlocked (DEK in RAM). Returns false if any
/// file failed to revert in which case the DEK is still present and the
/// file failed to revert - in which case the DEK is still present and the
/// device remains in lockdown so the operator can retry. APPROTECT is not
/// reversed. Called from the main loop via lockdownDisablePending.
bool disableLockdownToPlaintext();
@@ -430,11 +505,14 @@ class NodeDB
mutable concurrency::Lock satelliteMutex;
bool duplicateWarned = false;
bool localPositionUpdatedSinceBoot = false;
bool migrationSavePending = false;
/// Set when loadFromDisk() hit a present-but-undecodable config (DECODE_FAILED). The ctor uses it to
/// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum
/// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run.
bool configDecodeFailed = false;
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
uint32_t lastSort = 0; // When last sorted the nodeDB
/// Find a node in our DB, create an empty NodeInfoLite if missing
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
/*
* Internal boolean to track sorting paused
@@ -447,9 +525,33 @@ class NodeDB
/// read our db from flash
void loadFromDisk();
#ifdef PIO_UNIT_TESTING
// Grant the unit-test shim access to the private maintenance paths below
// (migration / cleanup / eviction) without relaxing production access.
friend class NodeDBTestShim;
#endif
/// purge db entries without user info
void cleanupMeshDB();
/// Trim each satellite map down to MAX_SATELLITE_NODES, dropping the
/// stalest entries (used after loading files written before the cap, or by
/// a build with a larger cap). Returns true iff anything was trimmed.
bool enforceSatelliteCaps();
/// Node-DB self-care; call only once identity is established (getNodeNum()
/// valid). Confirms self is present, trims/demotes only NON-self overflow, and
/// rewrites the store once when something changed (never while storage locked).
void nodeDBSelfCare();
#if WARM_NODE_COUNT > 0
/// A database from a larger-cap build (e.g. the pre-fork 150-node nRF52 store)
/// can exceed MAX_NUM_NODES on load. Rank the hot store, demote the oldest
/// overflow into the warm tier preserving {num, last_heard, public_key} so PKI
/// DMs survive instead of dropping on truncation.
void demoteOldestHotNodesToWarm();
#endif
/// Reinit device state from scratch (not loading from disk)
void installDefaultDeviceState(), installDefaultNodeDatabase(), installDefaultChannels(),
installDefaultConfig(bool preserveKey), installDefaultModuleConfig();
@@ -469,7 +571,7 @@ class NodeDB
bool migrateLegacyNodeDatabase();
// Route satellite-store decode entries straight into our maps instead of
// temp vectors. Must be paired disarm before any other NodeDatabase decode.
// temp vectors. Must be paired - disarm before any other NodeDatabase decode.
void armNodeDatabaseDecodeTargets();
void disarmNodeDatabaseDecodeTargets();
};
@@ -570,6 +672,12 @@ inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n)
{
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
}
/// A node that the eviction/migration paths must not drop: a favourite, an
/// ignored (blocked) node, or a manually-verified key.
inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n)
{
return nodeInfoLiteIsFavorite(n) || nodeInfoLiteIsIgnored(n) || nodeInfoLiteIsKeyManuallyVerified(n);
}
inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value)
{
+3
View File
@@ -14,6 +14,7 @@
#include "configuration.h"
#include "mesh-pb-constants.h"
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
#include "meshUtils.h"
#include <algorithm>
#include <cstring>
@@ -88,8 +89,10 @@ bool NodeDB::migrateLegacyNodeDatabase()
slim.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
strncpy(slim.long_name, legacy.user.long_name, sizeof(slim.long_name));
slim.long_name[sizeof(slim.long_name) - 1] = '\0';
sanitizeUtf8(slim.long_name, sizeof(slim.long_name)); // replace bad bytes so nanopb encode never fails
strncpy(slim.short_name, legacy.user.short_name, sizeof(slim.short_name));
slim.short_name[sizeof(slim.short_name) - 1] = '\0';
sanitizeUtf8(slim.short_name, sizeof(slim.short_name)); // same - v24 names may contain non-UTF-8 bytes
slim.hw_model = legacy.user.hw_model;
slim.role = legacy.user.role;
if (legacy.user.is_licensed)
+6 -2
View File
@@ -248,7 +248,7 @@ void PacketHistory::hashRemove(NodeNum sender, PacketId id)
return;
uint16_t idx = hashIndex[bucket];
if (idx < recentPacketsCapacity && recentPackets[idx].sender == sender && recentPackets[idx].id == id) {
// Found it delete and re-insert subsequent entries to maintain probe chain integrity
// Found it - delete and re-insert subsequent entries to maintain probe chain integrity
hashIndex[bucket] = HASH_EMPTY;
uint32_t next = (bucket + 1) & hashMask;
for (uint32_t j = 0; j < hashCapacity; j++) {
@@ -486,7 +486,11 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N
}
/* Check if a certain node was a relayer of a packet in the history given iterator
* @return true if node was indeed a relayer, false if not */
* @return true if node was indeed a relayer, false if not
* NOTE: intentionally byte-domain. Both `relayer` and relayed_by[] are on-wire last bytes, so this
* answers "did a relayer with this byte touch the packet" - correct without resolving to a NodeNum.
* The collision risk is neutralized where the result is consumed (route learning in
* NextHopRouter::sniffReceived now gates the write through NodeDB::resolveUniqueLastByte). */
bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, bool *wasSole)
{
bool found = false;
+117 -61
View File
@@ -12,6 +12,7 @@
#include "Channels.h"
#include "Default.h"
#include "FSCommon.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PacketHistory.h"
@@ -39,6 +40,17 @@
#include "Throttle.h"
#include <RTC.h>
namespace
{
constexpr uint8_t FILES_MANIFEST_LEVELS = 3;
constexpr size_t FILES_MANIFEST_MAX_COUNT = 64;
void releaseFilesManifest(std::vector<meshtastic_FileInfo> &filesManifest)
{
std::vector<meshtastic_FileInfo>().swap(filesManifest);
}
} // namespace
// Flag to indicate a heartbeat was received and we should send queue status
bool heartbeatReceived = false;
@@ -52,12 +64,12 @@ static constexpr size_t MAX_AUTH_SLOTS = 6;
// status produced for connection A (e.g. UNLOCKED with the active TTL,
// or UNLOCK_FAILED with a backoff) cannot be drained by connection B,
// which would otherwise learn that A just authenticated or just failed
// a real information leak across local clients.
// - a real information leak across local clients.
//
// File-scope rather than a per-PhoneAPI member because adding any
// non-trivial state directly to PhoneAPI broke USB-CDC enumeration on
// the current nRF52 framework; the auth-slot table next door uses the
// same workaround. Lifecycle is tied to the auth slot table both are
// same workaround. Lifecycle is tied to the auth slot table - both are
// keyed by PhoneAPI*, both are cleared together in clearAuthSlot_LH,
// and both share g_authSlotsMutex.
struct PendingStatusSlot {
@@ -66,7 +78,7 @@ struct PendingStatusSlot {
bool hasPending = false;
// True between a successful passphrase verify and the main-loop
// reloadFromDisk that follows. While set, the connection is NOT
// yet authorized and no UNLOCKED status has been emitted the
// yet authorized and no UNLOCKED status has been emitted - the
// client still sees LOCKED, and any admin op it tries is dropped
// by the existing unauth gates. Cleared either way by
// completePendingUnlocks once reload finishes.
@@ -95,7 +107,7 @@ static PendingStatusSlot *findOrAllocStatusSlot_LH(PhoneAPI *p)
// Mirror the auth-slot eviction policy: stale slots can be reused.
// A connection that lost its auth slot has nothing meaningful to be
// told via a pending status anyway. Never evict a slot mid-unlock
// (pendingUnlockAfterReload set) completing that flow on the
// (pendingUnlockAfterReload set) - completing that flow on the
// wrong PhoneAPI would authorize the wrong connection.
for (auto &s : g_statusSlots) {
if (!s.hasPending && !s.pendingUnlockAfterReload) {
@@ -131,7 +143,7 @@ static void buildStatus_LH(meshtastic_LockdownStatus &out, meshtastic_LockdownSt
memset(&out, 0, sizeof(out));
out.state = state;
// Collapse the specific token_* reasons to a generic "locked" over
// the wire full detail still goes to local logs. An unauth client
// the wire - full detail still goes to local logs. An unauth client
// does not need to know whether HMAC failed vs the boot count
// hit zero vs the file was the wrong size; all of those mean the
// same thing to the client ("locked, ask for passphrase") but
@@ -159,7 +171,7 @@ struct PhoneAuthSlot {
static PhoneAuthSlot g_authSlots[MAX_AUTH_SLOTS];
// Global auth epoch. Lock Now bumps it; per-slot `epoch` compared against
// this. Wraps at 2^32 revocations practically unreachable; on wrap the
// this. Wraps at 2^32 revocations - practically unreachable; on wrap the
// only behavioral effect is that any slot whose epoch happens to match the
// new low value would be treated as authorized again, which requires a
// pre-existing authorized slot to survive 2^32 lockNow events on the same
@@ -167,7 +179,7 @@ static PhoneAuthSlot g_authSlots[MAX_AUTH_SLOTS];
static uint32_t g_authEpoch = 1;
// Single mutex guarding g_authSlots and g_authEpoch. All readers and
// writers including const getters like getAdminAuthorized must take
// writers - including const getters like getAdminAuthorized - must take
// it. Granularity is fine because the critical sections are short (a
// fixed-size linear scan over 6 entries) and contention is dominated by
// getFromRadio's per-call redaction checks, which tolerate brief
@@ -179,7 +191,7 @@ static concurrency::Lock g_authSlotsMutex;
// evicts the first unauthorized slot found. Refuses to evict an authorized
// slot (those represent a live operator session and must outlive the table
// pressure of reconnect churn). Returns nullptr only if every slot is
// occupied by a different live, authorized PhoneAPI practically only
// occupied by a different live, authorized PhoneAPI - practically only
// reachable as a DoS via 7+ simultaneous authed connections, in which
// case fail-closed and log.
static PhoneAuthSlot *findOrAllocSlot_LH(PhoneAPI *p)
@@ -199,7 +211,7 @@ static PhoneAuthSlot *findOrAllocSlot_LH(PhoneAPI *p)
}
}
// Second pass: evict an unauthorized stale slot. Don't touch authorized
// ones those still represent an operator-authenticated session.
// ones - those still represent an operator-authenticated session.
for (auto &s : g_authSlots) {
if (!s.authorized) {
s.who = p;
@@ -297,18 +309,31 @@ void PhoneAPI::handleStartConfig()
state = STATE_SEND_MY_INFO;
}
pauseBluetoothLogging = true;
spiLock->lock();
#if defined(MESHTASTIC_EXCLUDE_FILES_MANIFEST)
// Skip the recursive FS walk. Used by platforms whose Zephyr LittleFS
// backend can't safely traverse a deep tree (e.g. nRF54L15) and platforms
// that don't support OTA browsing the manifest is only consumed by
// that don't support OTA browsing - the manifest is only consumed by
// companion apps for those flows.
filesManifest.clear();
releaseFilesManifest(filesManifest);
#else
filesManifest = getFiles("/", 10);
// Manifest is never read on the node-info-only path (STATE_SEND_FILEMANIFEST
// short-circuits to sendConfigComplete), so skip the SPI lock + FS walk.
if (config_nonce != SPECIAL_NONCE_ONLY_NODES) {
bool filesManifestLimited = false;
{
concurrency::LockGuard guard(spiLock);
filesManifest = getFiles("/", FILES_MANIFEST_LEVELS, FILES_MANIFEST_MAX_COUNT, &filesManifestLimited);
}
if (filesManifestLimited) {
LOG_WARN("Got %zu files in manifest (limited to %zu entries/depth %u)", filesManifest.size(),
FILES_MANIFEST_MAX_COUNT, static_cast<unsigned>(FILES_MANIFEST_LEVELS));
} else {
LOG_DEBUG("Got %zu files in manifest", filesManifest.size());
}
} else {
releaseFilesManifest(filesManifest);
}
#endif
spiLock->unlock();
LOG_DEBUG("Got %d files in manifest", filesManifest.size());
LOG_INFO("Start API client config millis=%u", millis());
// Protect against concurrent BLE callbacks: they run in NimBLE's FreeRTOS task and also touch nodeInfoQueue.
@@ -376,8 +401,7 @@ void PhoneAPI::close()
replayPhase = REPLAY_PHASE_IDLE;
}
packetForPhone = NULL;
filesManifest.clear();
filesManifest.shrink_to_fit();
releaseFilesManifest(filesManifest);
lastPortNumToRadio.clear();
fromRadioNum = 0;
config_nonce = 0;
@@ -420,12 +444,12 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
case meshtastic_ToRadio_packet_tag:
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
// Allow admin messages addressed to this device passphrase delivery must get through.
// Allow admin messages addressed to this device - passphrase delivery must get through.
// AdminModule handles its own is_managed gate for those.
// Block everything else unauthorized clients cannot inject mesh traffic.
// Block everything else - unauthorized clients cannot inject mesh traffic.
// Require the packet to carry a decoded (not encrypted) payload so portnum is valid.
// Refuse to match when our own node number is still 0 (NodeDB
// not yet loaded happens during the locked-default boot path
// not yet loaded - happens during the locked-default boot path
// before reloadFromDisk). Otherwise a packet with to==0 would
// satisfy the equality and bypass the gate.
NodeNum ourNum = nodeDB->getNodeNum();
@@ -516,9 +540,10 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
STATE_SEND_UIDATA,
STATE_SEND_OWN_NODEINFO,
STATE_SEND_METADATA,
STATE_SEND_CHANNELS
STATE_SEND_REGION_PRESETS, // region -> valid modem presets (one message)
STATE_SEND_CHANNELS,
STATE_SEND_CONFIG,
STATE_SEND_MODULE_CONFIG,
STATE_SEND_MODULECONFIG,
STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to the client
STATE_SEND_FILEMANIFEST,
STATE_SEND_COMPLETE_ID,
@@ -559,12 +584,12 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.my_info = myNodeInfo;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
// device_id is a stable hardware identifier useful for an attacker
// device_id is a stable hardware identifier - useful for an attacker
// to fingerprint / correlate the device across observations. Strip it
// for unauthenticated clients. my_node_num is kept (it's broadcast
// on the mesh anyway). pio_env / min_app_version reveal the exact
// build flavour, useful only for picking which known-CVE to try.
// nodedb_count stays clients need it to decide whether to pull
// nodedb_count stays - clients need it to decide whether to pull
// the node DB after unlocking.
fromRadioScratch.my_info.device_id.size = 0;
memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes));
@@ -632,11 +657,24 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
// position_flags, excluded_modules, optionsCount. None of it
// is needed to drive lockdown_auth, and most of it tells an
// attacker which CVE / behavior quirks to probe. Wipe the
// whole struct clients re-fetch once authenticated.
// whole struct - clients re-fetch once authenticated.
memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata));
}
#endif
state = STATE_SEND_REGION_PRESETS;
break;
case STATE_SEND_REGION_PRESETS:
// Tell the client which modem presets are legal in each region so its UI
// can block illegal region+preset combinations. This is public RF /
// regulatory information (region and modem_preset are already in the
// unauthenticated LoRa whitelist below), so it is sent unconditionally -
// even an unauthorized/locked-down client can render a correct picker.
LOG_DEBUG("Send region preset map");
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_region_presets_tag;
getRegionPresetMap(fromRadioScratch.region_presets);
state = STATE_SEND_CHANNELS;
config_state = 0; // STATE_SEND_CHANNELS indexes channels starting at 0
break;
case STATE_SEND_CHANNELS:
@@ -645,7 +683,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
if (!getAdminAuthorized()) {
// Unauthenticated: emit a zero-initialized Channel. fromRadioScratch
// was memset(0) at the top of getFromRadio(), so leaving .channel
// untouched gives the client an empty entry no name, no PSK, no
// untouched gives the client an empty entry - no name, no PSK, no
// role. Advances the state machine normally so config_complete_id
// still fires.
} else
@@ -710,7 +748,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
// private knobs (ignore_incoming list, override_duty_cycle,
// override_frequency, sx126x_rx_boosted_gain, tx_power,
// ignore_mqtt, fem_lna_mode, config_ok_to_mqtt, ...) stay
// hidden they tell an attacker how the operator has tuned
// hidden - they tell an attacker how the operator has tuned
// the device but are not needed by an unauth client.
meshtastic_Config_LoRaConfig whitelist = {};
whitelist.use_preset = config.lora.use_preset;
@@ -737,7 +775,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
if (!getAdminAuthorized()) {
// Unauthenticated: emit an empty SecurityConfig (zero-init from
// the top-of-loop memset). No private_key, no admin_keys, no
// public_key nothing for an attacker to inspect.
// public_key - nothing for an attacker to inspect.
//
// Provisioning state (NEEDS_PROVISION vs LOCKED) is conveyed via
// the FromRadio.lockdown_status proto sent post-config; clients
@@ -859,6 +897,23 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_tak_tag;
fromRadioScratch.moduleConfig.payload_variant.tak = moduleConfig.tak;
break;
#if !MESHTASTIC_EXCLUDE_BEACON
case meshtastic_ModuleConfig_mesh_beacon_tag:
LOG_DEBUG("Send module config: mesh beacon");
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
// Unauthenticated: emit an empty MeshBeaconConfig (zero-init from
// the top-of-loop memset). The embedded ChannelSettings
// (broadcast_offer_channel / broadcast_on_channel) carry PSKs that
// must not be visible to an unauth client.
} else
#endif
{
fromRadioScratch.moduleConfig.payload_variant.mesh_beacon = moduleConfig.mesh_beacon;
}
break;
#endif
default:
LOG_DEBUG("Unhandled module config type %d", config_state);
}
@@ -868,7 +923,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
if (config_state > (_meshtastic_AdminMessage_ModuleConfigType_MAX + 1)) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
// Unauthorized client: skip node DB and file manifest only send config complete
// Unauthorized client: skip node DB and file manifest - only send config complete
state = STATE_SEND_COMPLETE_ID;
} else
#endif
@@ -928,7 +983,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
// ONLY_NODES variants skip the manifest.
if (config_state == filesManifest.size() || config_nonce == SPECIAL_NONCE_ONLY_NODES) {
config_state = 0;
filesManifest.clear();
releaseFilesManifest(filesManifest);
// Skip to complete packet
sendConfigComplete();
} else {
@@ -955,7 +1010,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
} else if (mqttClientProxyMessageForPhone) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
releaseMqttClientProxyPhonePacket(); // Discard unauthorized client
releaseMqttClientProxyPhonePacket(); // Discard - unauthorized client
} else
#endif
{
@@ -966,7 +1021,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
} else if (xmodemPacketForPhone.control != meshtastic_XModem_Control_NUL) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
xmodemPacketForPhone = meshtastic_XModem_init_zero; // Discard unauthorized client
xmodemPacketForPhone = meshtastic_XModem_init_zero; // Discard - unauthorized client
} else
#endif
{
@@ -977,7 +1032,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
} else if (hasPendingLockdownStatus()) {
concurrency::LockGuard guard(&g_authSlotsMutex);
// Look up our own slot only never another connection's. Re-check
// Look up our own slot only - never another connection's. Re-check
// hasPending under the lock since a concurrent drain on the same
// connection (unlikely but possible if multiple transport
// callbacks race against one PhoneAPI) may have grabbed it.
@@ -995,7 +1050,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
} else if (packetForPhone) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
releasePhonePacket(); // Discard mesh traffic unauthorized client
releasePhonePacket(); // Discard mesh traffic - unauthorized client
} else
#endif
{
@@ -1006,7 +1061,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
releasePhonePacket();
}
} else if (replayPending()) {
// No live packet pending feed the phone one cached satellite-DB packet.
// No live packet pending - feed the phone one cached satellite-DB packet.
// popReplayPacket advances through positions->telemetry->environment->status,
// and flips replayPhase back to IDLE when everything has been drained.
meshtastic_MeshPacket replayPkt;
@@ -1184,7 +1239,7 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes
pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
pkt.hop_start = pkt.hop_limit;
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// Mark as if heard over the air, not internally generated iOS client filters
// Mark as if heard over the air, not internally generated - iOS client filters
// TRANSPORT_INTERNAL packets out of broadcast peer state updates.
pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
@@ -1289,7 +1344,7 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const
pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
pkt.hop_start = pkt.hop_limit;
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// Mark as if heard over the air, not internally generated iOS client filters
// Mark as if heard over the air, not internally generated - iOS client filters
// TRANSPORT_INTERNAL packets out of broadcast peer state updates.
pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
@@ -1353,7 +1408,7 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht
pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
pkt.hop_start = pkt.hop_limit;
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// Mark as if heard over the air, not internally generated client filters
// Mark as if heard over the air, not internally generated - client filters
pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
pkt.decoded.portnum = meshtastic_PortNum_NODE_STATUS_APP;
@@ -1445,7 +1500,7 @@ bool PhoneAPI::popReplayPacket(meshtastic_MeshPacket &out)
}
}
// Queue empty AND no more entries to feed it phase is exhausted.
// Queue empty AND no more entries to feed it - phase is exhausted.
advanceReplayPhase();
}
return false;
@@ -1517,6 +1572,7 @@ bool PhoneAPI::available()
case STATE_SEND_CONFIG:
case STATE_SEND_MODULECONFIG:
case STATE_SEND_METADATA:
case STATE_SEND_REGION_PRESETS:
case STATE_SEND_OWN_NODEINFO:
case STATE_SEND_FILEMANIFEST:
case STATE_SEND_COMPLETE_ID:
@@ -1570,7 +1626,7 @@ bool PhoneAPI::available()
hasPacket = !!packetForPhone;
if (hasPacket)
return true;
// Trailing replay drain feeds cached satellite-DB packets alongside
// Trailing replay drain - feeds cached satellite-DB packets alongside
// (lower priority than) live traffic.
return replayPending();
}
@@ -1627,7 +1683,7 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
// (b) Any other admin payload from an unauthorized connection:
// dropped here. The previous design relied on AdminModule
// to apply isLocalAdminAuthorized() during dispatch, but
// AdminModule runs on the Router task by then the
// AdminModule runs on the Router task - by then the
// PhoneAPI dispatching task has already exited and the
// per-connection auth context is unrecoverable. Putting
// the gate here closes that race and covers H6/H7 from the
@@ -1639,7 +1695,7 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) {
if (admin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) {
handleLockdownAuthInline(admin.lockdown_auth);
// Wipe the decoded passphrase scratch the byte array in
// Wipe the decoded passphrase scratch - the byte array in
// p.decoded.payload.bytes is wiped by handleLockdownAuthInline.
volatile uint8_t *adminVol = const_cast<volatile uint8_t *>(admin.lockdown_auth.passphrase.bytes);
for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++)
@@ -1730,7 +1786,7 @@ bool PhoneAPI::getAdminAuthorized() const
{
// Runtime-toggle model: when lockdown is NOT active (a lockdown-capable
// build that hasn't been provisioned, or that was disabled), there is
// nothing to protect every connection is implicitly authorized, so
// nothing to protect - every connection is implicitly authorized, so
// all the `if (!getAdminAuthorized())` redaction gates throughout
// getFromRadio() / handleToRadio() become no-ops and the device serves
// config exactly like stock firmware. Only once provisioned (lockdown
@@ -1740,7 +1796,7 @@ bool PhoneAPI::getAdminAuthorized() const
return true;
#endif
concurrency::LockGuard g(&g_authSlotsMutex);
// const_cast is safe findOrAllocSlot_LH only mutates the slot table,
// const_cast is safe - findOrAllocSlot_LH only mutates the slot table,
// not the PhoneAPI itself, and the table key is just the pointer.
const auto *slot = findOrAllocSlot_LH(const_cast<PhoneAPI *>(this));
return slot && slot->authorized && slot->epoch == g_authEpoch;
@@ -1751,7 +1807,7 @@ void PhoneAPI::setAdminAuthorized(bool authorized)
concurrency::LockGuard g(&g_authSlotsMutex);
auto *slot = findOrAllocSlot_LH(this);
if (!slot)
return; // slot table full fail-closed
return; // slot table full - fail-closed
if (authorized) {
slot->epoch = g_authEpoch;
slot->authorized = true;
@@ -1774,7 +1830,7 @@ void PhoneAPI::completePendingUnlocks(bool reloadOk)
{
// Snapshot fields that we'll need outside the lock (we cannot call
// EncryptedStorage / setAdminAuthorized / unlockScreen while holding
// g_authSlotsMutex without risking re-entry setAdminAuthorized
// g_authSlotsMutex without risking re-entry - setAdminAuthorized
// itself takes the same lock).
constexpr size_t kMaxSnapshots = MAX_AUTH_SLOTS;
PhoneAPI *targets[kMaxSnapshots] = {};
@@ -1786,7 +1842,7 @@ void PhoneAPI::completePendingUnlocks(bool reloadOk)
continue;
if (targetCount < kMaxSnapshots)
targets[targetCount++] = s.who;
// Clear the pending flag either way failure path must not
// Clear the pending flag either way - failure path must not
// leave it set so a subsequent successful reload retries
// against the wrong PhoneAPI.
s.pendingUnlockAfterReload = false;
@@ -1802,13 +1858,13 @@ void PhoneAPI::completePendingUnlocks(bool reloadOk)
p->queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", boots, until, 0);
}
// Screen-lock latch is cleared once any client successfully
// unlocks the operator has proven the passphrase. Matches the
// unlocks - the operator has proven the passphrase. Matches the
// re-verify path's behavior.
if (targetCount > 0)
meshtastic_security::unlockScreen();
LOG_INFO("Lockdown: post-reload completion: authorized %u connection(s)", (unsigned)targetCount);
} else {
// Storage corrupt emit LOCKED(storage_corrupt) to every slot
// Storage corrupt - emit LOCKED(storage_corrupt) to every slot
// that was awaiting the unlock. setAdminAuthorized is NOT called
// so the connection stays redacted and any set_config it sends
// is dropped at the existing unauth gates. Caller (main.cpp) has
@@ -1827,7 +1883,7 @@ void PhoneAPI::queueLockdownStatus(meshtastic_LockdownStatus_State state, const
concurrency::LockGuard guard(&g_authSlotsMutex);
auto *slot = findOrAllocStatusSlot_LH(this);
if (!slot)
return; // slot table exhausted fail-closed, no status delivered
return; // slot table exhausted - fail-closed, no status delivered
buildStatus_LH(slot->status, state, lock_reason, boots_remaining, valid_until_epoch, backoff_seconds);
slot->hasPending = true;
}
@@ -1875,14 +1931,14 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
ppVol[zi] = 0;
};
// Lock Now only honored from a connection that has already proven
// Lock Now - only honored from a connection that has already proven
// the passphrase. Unauthenticated clients used to be able to trigger
// a reboot, which was a trivial local-presence DoS (any BLE/USB
// attacker could brick-loop the device). Now lock_now requires
// prior auth on this connection.
if (la.lock_now) {
if (!getAdminAuthorized()) {
LOG_WARN("Lockdown: LOCK NOW from unauthorized connection denied");
LOG_WARN("Lockdown: LOCK NOW from unauthorized connection - denied");
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
zeroPassphrase();
return true;
@@ -1899,18 +1955,18 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
// Disable lockdown entirely. Requires the passphrase (must prove
// ownership before reverting at-rest encryption). We verify it here to
// load the DEK, then hand the heavy decrypt-revert work to the main
// loop via lockdownDisablePending exactly like the unlock reload
// loop via lockdownDisablePending - exactly like the unlock reload
// path, because decrypting + rewriting nodes.proto is too heavy for
// this transport-callback stack. APPROTECT is NOT reversed.
if (la.disable) {
if (la.passphrase.size < 1) {
LOG_WARN("Lockdown: disable with empty passphrase rejecting");
LOG_WARN("Lockdown: disable with empty passphrase - rejecting");
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
zeroPassphrase();
return true;
}
if (!EncryptedStorage::isLockdownActive()) {
// Already off nothing to do; report DISABLED so the client UI settles.
// Already off - nothing to do; report DISABLED so the client UI settles.
LOG_INFO("Lockdown: disable requested but lockdown is not active");
queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
zeroPassphrase();
@@ -1933,13 +1989,13 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
return true;
}
// Empty-passphrase auth was previously a silent success clients
// Empty-passphrase auth was previously a silent success - clients
// got no feedback and the device looked the same as it would after
// an actual no-op. Emit UNLOCK_FAILED with no backoff so honest
// clients can detect their own bug and an attacker still learns
// nothing they wouldn't from any other failed attempt.
if (la.passphrase.size < 1) {
LOG_WARN("Lockdown: lockdown_auth with empty passphrase and lock_now=false rejecting");
LOG_WARN("Lockdown: lockdown_auth with empty passphrase and lock_now=false - rejecting");
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
zeroPassphrase();
return true;
@@ -1978,7 +2034,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
if (ok) {
needsReload = true;
// Mark this slot for the main-loop completion handler. Don't
// authorize or emit UNLOCKED yet `config` / `channelFile`
// authorize or emit UNLOCKED yet - `config` / `channelFile`
// / `nodeDatabase` still hold the locked-default placeholders
// installed by loadFromDisk()'s !isUnlocked() branch. If we
// flipped the connection to authorized here, the client could
@@ -2001,7 +2057,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
sessionMaxSeconds);
if (ok) {
// Storage was already unlocked no reload needed. Authorize
// Storage was already unlocked - no reload needed. Authorize
// and surface UNLOCKED to the client immediately.
setAdminAuthorized(true);
LOG_INFO("Lockdown: passphrase verified, this connection authorized");
@@ -2016,13 +2072,13 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", EncryptedStorage::getBootsRemaining(),
EncryptedStorage::getValidUntilEpoch(), 0);
} else if (ok && needsReload) {
// Cold-unlock path: deliberately no status emission yet the
// Cold-unlock path: deliberately no status emission yet - the
// client keeps seeing LOCKED until completePendingUnlocks()
// runs after a successful reload.
} else {
uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, backoff);
// Don't log backoff seconds the client receives it in the
// Don't log backoff seconds - the client receives it in the
// UNLOCK_FAILED status anyway, and in non-DEBUG_MUTE builds the
// numeric value would otherwise spill onto a USB-attached
// attacker's serial terminal alongside other diagnostic noise.
+5 -4
View File
@@ -46,6 +46,7 @@ class PhoneAPI
STATE_SEND_MY_INFO, // send our my info record
STATE_SEND_OWN_NODEINFO,
STATE_SEND_METADATA,
STATE_SEND_REGION_PRESETS, // Send the region->valid-preset map (one message)
STATE_SEND_CHANNELS, // Send all channels
STATE_SEND_CONFIG, // Replacement for the old Radioconfig
STATE_SEND_MODULECONFIG, // Send Module specific config
@@ -174,7 +175,7 @@ class PhoneAPI
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
/// Per-connection auth: tracked in a small file-scope slot table keyed
/// by PhoneAPI*. Adding state members directly to PhoneAPI broke
/// USB-CDC enumeration on current nRF52 framework even one extra
/// USB-CDC enumeration on current nRF52 framework - even one extra
/// per-instance uint32_t was enough. Keeping all state out-of-line
/// avoids the issue.
void setAdminAuthorized(bool authorized);
@@ -256,10 +257,10 @@ class PhoneAPI
APIType api_type = TYPE_NONE;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// No per-instance auth members see method-level note. All state lives
// No per-instance auth members - see method-level note. All state lives
// in a file-scope slot table in PhoneAPI.cpp keyed by `this` pointer.
// Pending LockdownStatus storage is NOT a class member having a
// Pending LockdownStatus storage is NOT a class member - having a
// meshtastic_LockdownStatus (~50 bytes with the char[33] lock_reason)
// as a PhoneAPI member broke USB-CDC enumeration on the nRF52 Adafruit
// framework. The exact mechanism wasn't pinned down, but moving the
@@ -309,7 +310,7 @@ class PhoneAPI
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
/// Synchronously handle a lockdown_auth AdminMessage from the local
/// client. Runs inside handleToRadioPacket so the originating
/// connection is reachable via `this` avoids the async context
/// connection is reachable via `this` - avoids the async context
/// loss that broke the previous AdminModule path. Always consumes the
/// packet (returns true): lockdown_auth is local-only and must not be
/// forwarded to the mesh router.
+19 -2
View File
@@ -16,11 +16,23 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel)
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
{
return getPositionPrecisionForChannel(channels.getByIndex(channelIndex));
const meshtastic_Channel &ch = channels.getByIndex(channelIndex);
if (ch.role == meshtastic_Channel_Role_DISABLED)
return 0;
uint32_t precision = getPositionPrecisionForChannel(ch);
// Never send a precise position on a publicly-decryptable channel (key check is gated on > ceiling).
if (precision > MAX_POSITION_PRECISION_PUBLIC_KEY && channels.usesPublicKey(channelIndex)) {
precision = MAX_POSITION_PRECISION_PUBLIC_KEY;
}
return precision;
}
static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
{
if (precision == 0 || precision >= 32)
return coordinate;
uint32_t coordinateBits = static_cast<uint32_t>(coordinate);
uint32_t truncated = coordinateBits & (UINT32_MAX << (32 - precision));
@@ -30,6 +42,11 @@ static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
return static_cast<int32_t>(truncated);
}
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision)
{
return truncateCoordinate(coordinate, static_cast<uint32_t>(precision));
}
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision)
{
if (precision == 0) {
+15
View File
@@ -4,8 +4,23 @@
#include "meshtastic/mesh.pb.h"
#include <stdint.h>
// Max precision on a publicly-decryptable channel. CCPA "precise geolocation" = within a ~564m (1,850ft) radius.
// Precision is bit-truncation of latitude_i/longitude_i: the latitude cell stays ~constant in meters worldwide
// (~700m at 15 bits), while only the longitude cell varies - widest at the equator, narrowing toward the poles.
// 15 also matches the MQTT map-report public precision ceiling.
#define MAX_POSITION_PRECISION_PUBLIC_KEY 15
// Configured precision as-is; does NOT apply the public-key clamp -- use the channelIndex overload for the on-wire value.
uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
// Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the
// resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged.
// The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg.
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision);
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision);
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision);
bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision);
bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex);
+1 -1
View File
@@ -89,7 +89,7 @@ template <class T> class ProtobufModule : protected SinglePortModule
memset(&scratch, 0, sizeof(scratch));
if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, fields, &scratch)) {
decoded = &scratch;
LOG_INFO("Received %s from=0x%0x, id=0x%x, portnum=%d, payloadlen=%d", name, mp.from, mp.id, p.portnum,
LOG_INFO("Received %s from=0x%08x, id=0x%08x, portnum=%d, payloadlen=%d", name, mp.from, mp.id, p.portnum,
p.payload.size);
} else {
LOG_ERROR("Error decoding proto module!");
+90 -6
View File
@@ -174,7 +174,7 @@ const RegionInfo regions[] = {
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
https://standard.nbtc.go.th/getattachment/Standards/%E0%B8%A1%E0%B8%B2%E0%B8%95%E0%B8%A3%E0%B8%90%E0%B8%B2%E0%B8%99%E0%B8%97%E0%B8%B2%E0%B8%87%E0%B9%80%E0%B8%97%E0%B8%84%E0%B8%99%E0%B8%B4%E0%B8%84%E0%B8%82%E0%B8%AD%E0%B8%87%E0%B9%80%E0%B8%84%E0%B8%A3%E0%B8%B7%E0%B9%88%E0%B8%AD%E0%B8%87%E0%B9%82%E0%B8%97%E0%B8%A3%E0%B8%84%E0%B8%A1%E0%B8%99%E0%B8%B2%E0%B8%84%E0%B8%A1/1033-2565.pdf.aspx?lang=th-TH
Thailand 920925 MHz set max TX power to 27 dBm and enforce 10% duty cycle, aligned with NBTC regulations.
Thailand 920-925 MHz set max TX power to 27 dBm and enforce 10% duty cycle, aligned with NBTC regulations.
*/
RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
@@ -246,7 +246,7 @@ const RegionInfo regions[] = {
/*
ITU Region 1 (Europe, Africa, Middle East, former USSR) amateur 2m allocation: 144.000 - 146.000 MHz.
Power limit is the regulatory ceiling (1 W / 30 dBm) individual hardware will cap below this
Power limit is the regulatory ceiling (1 W / 30 dBm) - individual hardware will cap below this
via its own PA curve; the field here is just the legal upper bound.
Default slot: 26 (144.510 MHz)
@@ -283,6 +283,33 @@ const RegionInfo regions[] = {
*/
RDEF(ITU2_125CM, 220.0f, 225.0f, 100, 30, false, false, PROFILE_HAM_100KHZ, PRESET(NARROW_SLOW), 37),
/*
ITU Region 1 (Europe, Africa, Middle East, former USSR) amateur 70cm allocation: 430.000 - 440.000 MHz.
Power limit is the regulatory ceiling (1 W / 30 dBm) individual hardware will cap below this
via its own PA curve; the field here is just the legal upper bound.
Default slot: 37 (433.650 MHz)
*/
RDEF(ITU1_70CM, 430.0f, 440.0f, 100, 30, false, false, PROFILE_HAM_100KHZ, PRESET(NARROW_SLOW), 37),
/*
ITU Region 2 (Americas) amateur 70cm allocation: 420.000 - 450.000 MHz.
Typical admin rules (e.g. US FCC Part 97) allow well above 30 dBm for licensed operators.
Note: Some countries do not allocate 420-430 MHz or 440-450 MHz. Check local law!
Default slot: 137 (433.650 MHz)
*/
RDEF(ITU2_70CM, 420.0f, 450.0f, 100, 30, false, false, PROFILE_HAM_100KHZ, PRESET(NARROW_SLOW), 137),
/*
ITU Region 3 (Asia/Pacific) amateur 70cm allocation: 430.000 - 450.000 MHz.
Typical admin rules allow well above 30 dBm for licensed operators.
Note: Some countries do not allocate 440-450 MHz. Check local law!
Default slot: 37 (433.650 MHz)
*/
RDEF(ITU3_70CM, 430.0f, 450.0f, 100, 30, false, false, PROFILE_HAM_100KHZ, PRESET(NARROW_SLOW), 37),
/*
2.4 GHZ WLAN Band equivalent. Only for SX128x chips.
*/
@@ -605,6 +632,62 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
return r;
}
void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map)
{
map = meshtastic_LoRaRegionPresetMap_init_zero;
const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]);
const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]);
const size_t maxPresets = sizeof(map.groups[0].presets) / sizeof(map.groups[0].presets[0]);
// Coalesce regions that share an identical preset list into one group. Two
// regions belong to the same group when they share the same RegionProfile
// (which owns the preset list + licensing) AND the same default preset.
// Keyed by profile pointer, not the preset-array pointer: PROFILE_NARROW and
// PROFILE_HAM_100KHZ share PRESETS_NARROW but differ in licensedOnly.
const RegionProfile *groupProfile[sizeof(map.groups) / sizeof(map.groups[0])] = {};
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) {
// No room left to map any further region; once full we can't add more, so
// log once and stop. An incomplete map means clients won't constrain the
// omitted regions, so this must be discoverable rather than silent.
if (map.region_groups_count >= maxRegions) {
LOG_ERROR("Region preset map full at %u regions; remaining regions omitted", (unsigned)maxRegions);
break;
}
// Find the group this region belongs to, or create it.
int gi = -1;
for (pb_size_t g = 0; g < map.groups_count; g++) {
if (groupProfile[g] == r->profile && map.groups[g].default_preset == r->getDefaultPreset()) {
gi = g;
break;
}
}
if (gi < 0) {
if (map.groups_count >= maxGroups) {
// Out of group slots (should not happen for the current table). The
// region can't be advertised; skip it but make the gap visible.
LOG_ERROR("Region preset map out of group slots (%u); region %d omitted", (unsigned)maxGroups, r->code);
continue;
}
gi = map.groups_count++;
groupProfile[gi] = r->profile;
meshtastic_LoRaPresetGroup &grp = map.groups[gi];
grp.default_preset = r->getDefaultPreset();
grp.licensed_only = r->profile->licensedOnly;
grp.presets_count = 0;
for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++)
grp.presets[grp.presets_count++] = r->profile->presets[i];
}
// Map this region to its group (capacity checked at the top of the loop).
meshtastic_LoRaRegionPresets &rg = map.region_groups[map.region_groups_count++];
rg.region = r->code;
rg.group_index = (uint8_t)gi;
}
}
/**
* Get duty cycle for current region. EU_866: 10% for routers, 2.5% for mobile.
*/
@@ -714,11 +797,12 @@ uint32_t RadioInterface::getTxDelayMsecWeighted(meshtastic_MeshPacket *p)
return delay;
}
// Node IDs and packet IDs are formatted as 0x%08x in logs, and !%08x in user-facing display.
void printPacket(const char *prefix, const meshtastic_MeshPacket *p)
{
#if defined(DEBUG_PORT) && !defined(DEBUG_MUTE)
std::string out =
DEBUG_PORT.mt_sprintf("%s (id=0x%08x fr=0x%08x to=0x%08x, transport = %u, WantAck=%d, HopLim=%d Ch=0x%x", prefix, p->id,
DEBUG_PORT.mt_sprintf("%s (id=0x%08x fr=0x%08x to=0x%08x, transport = %u, WantAck=%d, HopLim=%d Ch=%d", prefix, p->id,
p->from, p->to, p->transport_mechanism, p->want_ack, p->hop_limit, p->channel);
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
auto &s = p->decoded;
@@ -732,13 +816,13 @@ void printPacket(const char *prefix, const meshtastic_MeshPacket *p)
out += DEBUG_PORT.mt_sprintf(" PKI");
if (s.source != 0)
out += DEBUG_PORT.mt_sprintf(" source=%08x", s.source);
out += DEBUG_PORT.mt_sprintf(" source=0x%08x", s.source);
if (s.dest != 0)
out += DEBUG_PORT.mt_sprintf(" dest=%08x", s.dest);
out += DEBUG_PORT.mt_sprintf(" dest=0x%08x", s.dest);
if (s.request_id)
out += DEBUG_PORT.mt_sprintf(" requestId=%0x", s.request_id);
out += DEBUG_PORT.mt_sprintf(" requestId=0x%08x", s.request_id);
/* now inside Data and therefore kinda opaque
if (s.which_ackVariant == SubPacket_success_id_tag)
+43 -3
View File
@@ -8,6 +8,9 @@
#include "error.h"
#include "main.h"
#include "mesh-pb-constants.h"
#if !MESHTASTIC_EXCLUDE_BEACON
#include "modules/MeshBeaconModule.h"
#endif
#include <pb_decode.h>
#include <pb_encode.h>
@@ -244,7 +247,7 @@ bool RadioLibInterface::cancelSending(NodeNum from, PacketId id)
packetPool.release(p); // free the packet we just removed
bool result = (p != NULL);
LOG_DEBUG("cancelSending id=0x%x, removed=%d", id, result);
LOG_DEBUG("cancelSending id=0x%08x, removed=%d", id, result);
return result;
}
@@ -365,7 +368,15 @@ void RadioLibInterface::onNotify(uint32_t notification)
switch (notification) {
case ISR_TX:
handleTransmitInterrupt();
handleTransmitInterrupt(); // completeSending() already restored the radio to the home config
#if !MESHTASTIC_EXCLUDE_BEACON
// Pre-switch the radio to the NEXT queued packet's beacon config (no-op for normal traffic).
// Not required for correctness - TRANSMIT_DELAY_COMPLETED would switch before CAD anyway - but
// doing it here lets the next beacon skip the switch-only delay cycle and, more importantly,
// keeps the post-TX listen window (and the CAD/LBT that follows) on the channel we're about to
// transmit on. Only engages when the next packet is itself a beacon - exactly when we want it.
MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront());
#endif
startReceive();
setTransmitDelay();
break;
@@ -388,9 +399,27 @@ void RadioLibInterface::onNotify(uint32_t notification)
if (delay_remaining > 0) {
// There's still some delay pending on this packet, so resume waiting for it to elapse
notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, false);
#if !MESHTASTIC_EXCLUDE_BEACON
} else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) {
// The beacon's target radio config is invalid (bad preset/region, or an
// unlicensed node keying up on a ham-only region). Drop the packet - never
// transmit it on the current (home) config - and move on to the next queued packet.
LOG_DEBUG("Beacon: invalid TX radio config, dropping packet 0x%08x", txp->id);
meshtastic_MeshPacket *bad = txQueue.dequeue();
MeshBeaconModule::clearTargetRadioSettings(bad);
packetPool.release(bad);
setTransmitDelay();
} else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) {
setTransmitDelay();
#endif
} else {
if (isChannelActive()) { // check if there is currently a LoRa packet on the channel
startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again
#if !MESHTASTIC_EXCLUDE_BEACON
if (!MeshBeaconModule::hasTargetRadioSettings(txp))
#endif
{
startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again
}
setTransmitDelay();
} else {
// Send any outgoing packets we have ready as fast as possible to keep the time between channel scan and
@@ -522,6 +551,10 @@ void RadioLibInterface::completeSending()
if (!isFromUs(p))
txRelay++;
printPacket("Completed sending", p);
#if !MESHTASTIC_EXCLUDE_BEACON
MeshBeaconModule::clearTargetRadioSettings(p);
MeshBeaconModule::reconfigureForBeaconTX(this, nullptr);
#endif
// We are done sending that packet, release it
packetPool.release(p);
@@ -682,6 +715,13 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp)
channel scan and actual transmit as low as possible to avoid collisions. */
if (disabled || !config.lora.tx_enabled) {
LOG_WARN("Drop Tx packet because LoRa Tx disabled");
#if !MESHTASTIC_EXCLUDE_BEACON
// This packet may have already triggered a beacon radio switch in TRANSMIT_DELAY_COMPLETED;
// since it never reaches completeSending() here, restore the radio so it isn't left on the
// beacon config (which would also break RX on the home channel).
MeshBeaconModule::clearTargetRadioSettings(txp);
MeshBeaconModule::reconfigureForBeaconTX(this, nullptr);
#endif
packetPool.release(txp);
return false;
} else {
+1 -1
View File
@@ -160,7 +160,7 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified
/**
* Reset AGC by power-cycling the analog frontend.
* Subclasses override with chip-specific calibration sequences.
* Safe to call periodically skips if currently sending or receiving.
* Safe to call periodically - skips if currently sending or receiving.
*/
virtual void resetAGC();
+5 -1
View File
@@ -148,9 +148,13 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
if ((ackId || nakId) &&
// Implicit ACKs from MQTT should not stop retransmissions
!(isFromUs(p) && p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT)) {
LOG_DEBUG("Received a %s for 0x%x, stopping retransmissions", ackId ? "ACK" : "NAK", ackId);
LOG_DEBUG("Received a %s for 0x%08x, stopping retransmissions", ackId ? "ACK" : "NAK", ackId);
if (ackId) {
stopRetransmission(p->to, ackId);
// M3: an end-to-end ACK proves the directed route to the ACK's sender currently works,
// so clear its failure count and refresh freshness (keeps a good route pinned).
if (!isBroadcast(getFrom(p)))
noteRouteSuccess(getFrom(p), millis());
} else {
stopRetransmission(p->to, nakId);
}
+138 -91
View File
@@ -12,6 +12,7 @@
#include "mesh-pb-constants.h"
#include "meshUtils.h"
#include "modules/RoutingModule.h"
#include <pb_encode.h>
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
@@ -100,51 +101,31 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
return true;
}
#if HAS_TRAFFIC_MANAGEMENT
// When router_preserve_hops is enabled, preserve hops for decoded packets that are not
// position or telemetry (those have their own exhaust_hop controls).
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled &&
moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) {
LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p));
if (trafficManagementModule) {
trafficManagementModule->recordRouterHopPreserved();
}
return false;
}
#endif
// router_preserve_hops: not suitable right now - removed from config until
// the right heuristics for when to preserve vs. exhaust hops are established.
// #if HAS_TRAFFIC_MANAGEMENT
// if (moduleConfig.has_traffic_management &&
// moduleConfig.traffic_management.router_preserve_hops && ...) { ... }
// #endif
// For subsequent hops, check if previous relay is a favorite router
// Optimized search for favorite routers with matching last byte
// Check ordering optimized for IoT devices (cheapest checks first)
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
if (!node)
continue;
// Check 1: is_favorite (cheapest - single bit test)
if (!nodeInfoLiteIsFavorite(node))
continue;
// Check 2: has_user (cheap - single bit test)
if (!nodeInfoLiteHasUser(node))
continue;
// Check 3: role check (moderate cost - multiple comparisons)
if (!IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
continue;
}
// Check 4: last byte extraction and comparison (most expensive)
if (nodeDB->getLastByteOfNodeNum(node->num) == p->relay_node) {
// Found a favorite router match
LOG_DEBUG("Identified favorite relay router 0x%x from last byte 0x%x", node->num, p->relay_node);
// For subsequent hops, preserve hop_limit only when the previous relay is UNAMBIGUOUSLY a favorite
// router. The relay_node byte is just the last byte of a 32-bit node number, so on a dense mesh it
// collides; the old "first matching node wins" scan could preserve hops for the wrong node
// (non-deterministic, depends on NodeDB order). resolveLastByte() reports a collision instead, and
// we re-check the favorite/router predicate on the single resolved node. On ambiguity/none we
// decrement (the safe default).
NodeNum resolved = 0;
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false, &resolved)) {
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(resolved);
if (node && nodeInfoLiteIsFavorite(node) && nodeInfoLiteHasUser(node) &&
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
LOG_DEBUG("Identified unique favorite relay router 0x%08x from last byte 0x%x", resolved, p->relay_node);
return false; // Don't decrement hop_limit
}
}
// No favorite router match found, decrement hop_limit
// No unambiguous favorite router match found, decrement hop_limit
return true;
}
@@ -464,13 +445,65 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout
// FIXME, update nodedb here for any packet that passes through us
}
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize)
{
// Only a signature we verify below may mark this packet signed; never trust an inbound flag.
p->xeddsa_signed = false;
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && node->public_key.size == 32) {
p->xeddsa_signed =
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
if (p->xeddsa_signed) {
// Learn this node as a signer, so a later unsigned signable broadcast from it is dropped
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
} else {
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
return false;
}
} else {
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
}
} else if (p->decoded.xeddsa_signature.size != 0) {
// A signature field that is neither empty nor a full 64 bytes is malformed - honest
// senders emit only those two sizes (perhapsEncode sets 0 or XEDDSA_SIGNATURE_SIZE). Drop
// it: a crafted partial signature would otherwise land in the unsigned branch below while
// its bytes inflated the size estimate, letting a forged broadcast dodge the downgrade drop.
LOG_WARN("Malformed XEdDSA signature (%u bytes) from 0x%08x, dropping", (unsigned)p->decoded.xeddsa_signature.size,
p->from);
return false;
} else {
// Truly unsigned (signature size 0) - only reject the class a signing node always signs: a
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. encodedDataSize is
// the size of the encoded Data exactly as the sender built it (or 0 to size p->decoded
// canonically); with no signature field present it is the unsigned base, and adding
// XEDDSA_SIGNATURE_FIELD_BYTES mirrors the sender-side signedDataFits() gate per packet,
// whatever fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a
// signature are never signed, so they must not be hard-failed here even for a known signer.
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) {
if (encodedDataSize == 0 && !pb_get_encoded_size(&encodedDataSize, &meshtastic_Data_msg, &p->decoded))
return true; // can't size it; never drop on a sizing failure
if (encodedDataSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) {
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
return false;
}
}
}
return true;
}
#endif
DecodeState perhapsDecode(meshtastic_MeshPacket *p)
{
concurrency::LockGuard g(cryptLock);
if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY &&
!nodeInfoLiteHasUser(nodeDB->getMeshNode(p->from))) {
LOG_DEBUG("Node 0x%x not in nodeDB-> Rebroadcast mode KNOWN_ONLY will ignore packet", p->from);
LOG_DEBUG("Node 0x%08x not in nodeDB-> Rebroadcast mode KNOWN_ONLY will ignore packet", p->from);
return DecodeState::DECODE_FAILURE;
}
@@ -485,14 +518,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
bool decrypted = false;
ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI)
// Attempt PKI decryption first
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->getMeshNode(p->from) != nullptr &&
nodeDB->getMeshNode(p->from)->public_key.size > 0 && nodeDB->getMeshNode(p->to) != nullptr &&
nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) {
// Attempt PKI decryption first. The sender's key may come from the hot
// store or the warm tier (nodes evicted from the hot store keep their key
// there), so DMs from long-tail nodes still decrypt.
meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}};
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) &&
nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 &&
rawSize > MESHTASTIC_PKC_OVERHEAD) {
LOG_DEBUG("Attempt PKI decryption");
if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->public_key, p->id, rawSize, p->encrypted.bytes,
bytes)) {
if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) {
LOG_INFO("PKI Decryption worked!");
meshtastic_Data decodedtmp;
@@ -503,7 +538,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
decrypted = true;
LOG_INFO("Packet decrypted using PKI!");
p->pki_encrypted = true;
memcpy(&p->public_key.bytes, nodeDB->getMeshNode(p->from)->public_key.bytes, 32);
memcpy(p->public_key.bytes, fromKey.bytes, 32);
p->public_key.size = 32;
p->decoded = decodedtmp;
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
@@ -560,35 +595,11 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && node->public_key.size == 32) {
p->xeddsa_signed =
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
if (p->xeddsa_signed) {
// Mark this node as a signer so future unsigned packets from it are rejected
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
} else {
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
return DecodeState::DECODE_FAILURE;
}
} else {
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
}
} else {
// Unsigned packet — only reject the class of packet a signing node always signs:
// an unencrypted broadcast small enough to also carry a signature (see perhapsEncode()).
// Unicast packets and oversized broadcasts are never signed, so they must not be
// hard-failed here even if this node has signed before.
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && nodeInfoLiteHasXeddsaSigned(node) && isBroadcast(p->to) &&
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
return DecodeState::DECODE_FAILURE;
}
}
// rawSize is the size of the encoded Data exactly as the sender built it (the PKI branch's
// MESHTASTIC_PKC_OVERHEAD subtraction preserves that, and PKI packets are unicast so the
// downgrade predicate ignores them anyway).
if (!checkXeddsaReceivePolicy(p, rawSize))
return DecodeState::DECODE_FAILURE;
#endif
/* Not actually ever used.
@@ -647,6 +658,20 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
}
}
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
/** Exact sender-side sign gate: would this Data still fit the LoRa frame with a 64-byte
* signature attached? Sized with the real encoder so it tracks whatever fields are present. */
static bool signedDataFits(meshtastic_Data *d)
{
const pb_size_t prevSize = d->xeddsa_signature.size;
d->xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
size_t encodedSize;
const bool sized = pb_get_encoded_size(&encodedSize, &meshtastic_Data_msg, d);
d->xeddsa_signature.size = prevSize;
return sized && encodedSize + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN;
}
#endif
/** Return 0 for success or a Routing_Error code for failure
*/
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
@@ -661,11 +686,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
p->decoded.has_bitfield = true;
p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT);
p->decoded.bitfield |= (p->decoded.want_response << BITFIELD_WANT_RESPONSE_SHIFT);
// We own signing for packets we originate; discard any signature a client preset.
// Outside the XEdDSA guard: the field exists in the protobuf on every build, and a
// stale/garbage signature transmitted by a non-signing build would hard-fail
// verification at every XEdDSA-enabled receiver that knows our key.
p->decoded.xeddsa_signature.size = 0;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Sign broadcast packets if payload + signature fits within the max Data payload.
// The actual encoded size is checked after pb_encode (TOO_LARGE).
if (!p->pki_encrypted && isBroadcast(p->to) &&
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
// Sign broadcast packets when the Data still fits a LoRa frame with the signature
// attached. This must be the exact encoded-size criterion, not a payload-size
// heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
// deciding whether an unsigned broadcast from a known signer is a downgrade.
if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) {
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
p->decoded.xeddsa_signature.bytes)) {
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
@@ -720,7 +752,10 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it
#if !(MESHTASTIC_EXCLUDE_PKI)
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to);
// Destination key from the hot store or the warm tier (evicted
// long-tail nodes keep their key there)
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
bool haveDestKey = nodeDB->copyPublicKey(p->to, destKey);
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
// is not in the local nodedb
// First, only PKC encrypt packets we are originating
@@ -743,18 +778,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
return meshtastic_Routing_Error_TOO_LARGE;
// Check for a known public key for the destination
if (node == nullptr || node->public_key.size != 32) {
if (!haveDestKey) {
LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to,
p->decoded.portnum);
return meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY;
}
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) &&
memcmp(p->public_key.bytes, node->public_key.bytes, 32) != 0) {
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) && memcmp(p->public_key.bytes, destKey.bytes, 32) != 0) {
LOG_WARN("Client public key differs from requested: 0x%02x, stored key begins 0x%02x", *p->public_key.bytes,
*node->public_key.bytes);
*destKey.bytes);
return meshtastic_Routing_Error_PKI_FAILED;
}
crypto->encryptCurve25519(p->to, getFrom(p), node->public_key, p->id, numbytes, bytes, p->encrypted.bytes);
crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes);
numbytes += MESHTASTIC_PKC_OVERHEAD;
p->channel = 0;
p->pki_encrypted = true;
@@ -848,6 +882,19 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
skipHandle = true;
}
#if !MESHTASTIC_EXCLUDE_BEACON
// Beacon listening is disabled: drop beacon packets so they are neither surfaced to the
// phone nor handled on-device (same pattern as the disabled neighbor-info case above).
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP &&
(!moduleConfig.has_mesh_beacon ||
!(moduleConfig.mesh_beacon.flags & meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED))) {
LOG_DEBUG("Beacon listening is disabled, ignore beacon packet");
cancelSending(p->from, p->id);
skipHandle = true;
}
#endif
bool shouldIgnoreNonstandardPorts =
config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY;
#if USERPREFS_EVENT_MODE
@@ -928,14 +975,14 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
#endif
// assert(radioConfig.has_preferences);
if (is_in_repeated(config.lora.ignore_incoming, p->from)) {
LOG_DEBUG("Ignore msg, 0x%x is in our ignore list", p->from);
LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from);
packetPool.release(p);
return;
}
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
if (nodeInfoLiteIsIgnored(node)) {
LOG_DEBUG("Ignore msg, 0x%x is ignored", p->from);
LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from);
packetPool.release(p);
return;
}
@@ -947,7 +994,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
}
if (config.lora.ignore_mqtt && p->via_mqtt) {
LOG_DEBUG("Msg came in via MQTT from 0x%x", p->from);
LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from);
packetPool.release(p);
return;
}
@@ -959,7 +1006,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
}
if (shouldFilterReceived(p)) {
LOG_DEBUG("Incoming msg was filtered from 0x%x", p->from);
LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from);
packetPool.release(p);
return;
}
+21 -1
View File
@@ -36,7 +36,7 @@ class Router : protected concurrency::OSThread, protected PacketHistory
void addInterface(std::unique_ptr<RadioInterface> _iface) { iface = std::move(_iface); }
/**
* Borrowed (non-owning) access to the radio interface used by NodeDB
* Borrowed (non-owning) access to the radio interface - used by NodeDB
* after a lockdown unlock so it can push the freshly-loaded config to
* the SX12xx via reconfigure(). Returns nullptr when no radio has been
* attached (e.g. ARCH_PORTDUINO simulator before SimRadio bind).
@@ -175,6 +175,26 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p);
*/
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p);
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
/** XEdDSA receive-side signature policy. When the packet carries a 64-byte signature *and* the
* sender's public key is known, verify it: on success learn the sender's signer bit, on failure
* drop. If the key is unknown the signature is left unverified and the packet passes. A signature
* of any other non-zero length is treated as malformed and dropped. For unsigned packets, enforce
* downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would
* still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass).
*
* encodedDataSize is the wire size of the encoded Data as the sender built it; pass 0 to size
* p->decoded canonically instead (for already-decoded ingress such as plaintext-MQTT downlink,
* which bypasses perhapsDecode's crypto path).
*
* The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache.
* (perhapsDecode already holds it; other call sites must take it themselves.)
*
* @return false if the packet must be dropped.
*/
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize = 0);
#endif
extern Router *router;
/// Generate a unique packet id
+17 -6
View File
@@ -251,11 +251,14 @@ template <typename T> bool SX126xInterface<T>::reconfigure()
power = -9;
err = lora.setOutputPower(power);
if (err != RADIOLIB_ERR_NONE)
LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err);
assert(err == RADIOLIB_ERR_NONE);
if (err != RADIOLIB_ERR_NONE) {
// Don't abort: this power is operator config (tx_power/SX126X_MAX_POWER); a value above the
// driver's max would crash the daemon before reloadConfig() persists. Flag it and keep prior power.
LOG_ERROR("SX126X setOutputPower %d dBm rejected (%s%d); keeping previous Tx power", power, radioLibErr, err);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
}
// Apply RX gain mode valid in STDBY (datasheet §9.6), matches resetAGC() pattern
// Apply RX gain mode - valid in STDBY (datasheet §9.6), matches resetAGC() pattern
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
if (err != RADIOLIB_ERR_NONE)
LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err);
@@ -328,10 +331,18 @@ template <typename T> void SX126xInterface<T>::startReceive()
setTransmitEnable(false);
setStandby();
#ifdef ARCH_PORTDUINO_WASM
// Continuous RX in the browser: duty-cycle sleep parks BUSY high between RX
// windows and stalls the slow WebUSB SPI link. No battery to save here.
int err = lora.startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS);
const char *rxMethod = "startReceive";
#else
// We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly.
int err = lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS);
const char *rxMethod = "startReceiveDutyCycleAuto";
#endif
if (err != RADIOLIB_ERR_NONE)
LOG_ERROR("SX126X startReceiveDutyCycleAuto %s%d", radioLibErr, err);
LOG_ERROR("SX126X %s %s%d", rxMethod, radioLibErr, err);
#ifdef ARCH_PORTDUINO
if (err != RADIOLIB_ERR_NONE)
portduino_status.LoRa_in_error = true;
@@ -418,7 +429,7 @@ template <typename T> void SX126xInterface<T>::resetAGC()
LOG_DEBUG("SX126x AGC reset: warm sleep + Calibrate(0x7F)");
// 1. Warm sleep powers down the entire analog frontend, resetting AGC state.
// 1. Warm sleep - powers down the entire analog frontend, resetting AGC state.
// A plain standby→startReceive cycle does NOT reset the AGC.
lora.sleep(true);
+31 -28
View File
@@ -52,7 +52,8 @@ void StreamAPI::writeStream()
do {
// Send every packet we can
len = getFromRadio(txBuf + HEADER_LEN);
emitTxBuffer(len);
if (len != 0 && !emitTxBuffer(len))
break;
} while (len);
}
}
@@ -169,21 +170,36 @@ int32_t StreamAPI::readStream()
/**
* Send the current txBuffer over our stream
*/
void StreamAPI::emitTxBuffer(size_t len)
bool StreamAPI::writeFrame(uint8_t *buf, size_t len)
{
if (len != 0) {
txBuf[0] = START1;
txBuf[1] = START2;
txBuf[2] = (len >> 8) & 0xff;
txBuf[3] = len & 0xff;
if (len == 0 || !canWrite)
return false;
auto totalLen = len + HEADER_LEN;
// Serialize stream writes against `emitLogRecord` so a LOG_ firing
// mid-packet-emission can't interleave bytes on the wire.
concurrency::LockGuard guard(&streamLock);
stream->write(txBuf, totalLen);
buf[0] = START1;
buf[1] = START2;
buf[2] = (len >> 8) & 0xff;
buf[3] = len & 0xff;
auto totalLen = len + HEADER_LEN;
// Serialize write-readiness checks, writes and write-failure handling
// against concurrent stream writes/close.
concurrency::LockGuard guard(&streamLock);
if (!canWriteFrame(totalLen))
return false;
size_t written = stream->write(buf, totalLen);
if (written == totalLen) {
stream->flush();
return true;
}
onFrameWriteFailed(totalLen, written);
return false;
}
bool StreamAPI::emitTxBuffer(size_t len)
{
return writeFrame(txBuf, len);
}
void StreamAPI::emitRebooted()
@@ -199,7 +215,7 @@ void StreamAPI::emitRebooted()
void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg)
{
// IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here those
// IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here - those
// belong to the main packet-emission path and a LOG_ firing during
// `writeStream()` would corrupt an in-flight encode. We keep a
// dedicated `fromRadioScratchLog` + `txBufLog` for log records and
@@ -221,20 +237,7 @@ void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src,
size_t len =
pb_encode_to_bytes(txBufLog + HEADER_LEN, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratchLog);
if (len != 0) {
txBufLog[0] = START1;
txBufLog[1] = START2;
txBufLog[2] = (len >> 8) & 0xff;
txBufLog[3] = len & 0xff;
auto totalLen = len + HEADER_LEN;
// Serialize stream writes against `emitTxBuffer` so a packet
// emission in flight on another task doesn't interleave bytes
// with this log record.
concurrency::LockGuard guard(&streamLock);
stream->write(txBufLog, totalLen);
stream->flush();
}
writeFrame(txBufLog, len);
}
/// Hookable to find out when connection changes
@@ -249,4 +252,4 @@ void StreamAPI::onConnectionChanged(bool connected)
// received a packet in a while
powerFSM.trigger(EVENT_SERIAL_DISCONNECTED);
}
}
}
+8 -3
View File
@@ -80,7 +80,7 @@ class StreamAPI : public PhoneAPI
/**
* Send the current txBuffer over our stream
*/
void emitTxBuffer(size_t len);
bool emitTxBuffer(size_t len);
/// Are we allowed to write packets to our output stream (subclasses can turn this off - i.e. SerialConsole)
bool canWrite = true;
@@ -91,7 +91,12 @@ class StreamAPI : public PhoneAPI
/// Low level function to emit a protobuf encapsulated log record
void emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg);
virtual bool canWriteFrame(size_t frameLen) { return true; }
virtual void onFrameWriteFailed(size_t frameLen, size_t writtenLen) {}
private:
bool writeFrame(uint8_t *buf, size_t len);
/// Dedicated scratch + tx buffer for LogRecord emission.
///
/// The main packet emission path (`writeStream` -> `getFromRadio` ->
@@ -102,7 +107,7 @@ class StreamAPI : public PhoneAPI
/// re-used `fromRadioScratch` / `txBuf` and corrupted whatever the main
/// path had already encoded. Symptoms on the host were
/// `google.protobuf.message.DecodeError: Error parsing message with type
/// 'meshtastic.protobuf.FromRadio'` any tool with
/// 'meshtastic.protobuf.FromRadio'` - any tool with
/// `config.security.debug_log_api_enabled=true` under traffic would see
/// torn frames every few messages.
///
@@ -113,4 +118,4 @@ class StreamAPI : public PhoneAPI
meshtastic_FromRadio fromRadioScratchLog = {};
uint8_t txBufLog[MAX_STREAM_BUF_SIZE] = {0};
concurrency::Lock streamLock;
};
};
+3 -3
View File
@@ -87,7 +87,7 @@ void TransmitHistory::setLastSentToMesh(uint16_t key)
const uint8_t flags = (getRTCQuality() == RTCQualityNone) ? ENTRY_FLAG_BOOT_RELATIVE : ENTRY_FLAG_NONE;
history[key] = makeStoredTimestamp(now, flags);
dirty = true;
// Don't flush to disk on every transmit flash has limited write endurance.
// Don't flush to disk on every transmit - flash has limited write endurance.
// The in-memory lastMillis map handles throttle during normal operation.
// Disk is flushed: before deep sleep (sleep.cpp) and periodically here,
// throttled to at most once per 5 minutes. Always save the first time
@@ -189,7 +189,7 @@ uint32_t TransmitHistory::getLastSentToMeshMillis(uint16_t key) const
// Fall back to epoch conversion (loaded from disk after reboot)
auto it = history.find(key);
if (it == history.end() || it->second.seconds == 0) {
return 0; // No stored time module has never sent
return 0; // No stored time - module has never sent
}
// Convert to a millis()-relative timestamp: millis() - msAgo.
@@ -271,7 +271,7 @@ void TransmitHistory::clear()
}
#else
// No filesystem available provide stub with in-memory tracking
// No filesystem available - provide stub with in-memory tracking
TransmitHistory *transmitHistory = nullptr;
TransmitHistory *TransmitHistory::getInstance()
+1 -1
View File
@@ -78,7 +78,7 @@ class TransmitHistory
/**
* Wipe in-memory throttle state + remove the on-disk file. Required
* alongside rmDir("/prefs") in factoryReset otherwise the 5-min
* alongside rmDir("/prefs") in factoryReset - otherwise the 5-min
* auto-flush resurrects the file from the still-populated maps.
*/
void clear();
+1 -1
View File
@@ -94,7 +94,7 @@ meshtastic_Position TypeConversions::ConvertToPosition(meshtastic_PositionLite l
position.time = lite.time;
// Preserve the peer's broadcast precision; falls back to 0 for entries cached
// before the precision_bits field existed in PositionLite (pre-migration data).
// iOS treats 0 as "unspecified precision" and won't render the pin so for
// iOS treats 0 as "unspecified precision" and won't render the pin - so for
// unset values, declare full precision so the stored lat/lon renders as a point.
position.precision_bits = lite.precision_bits == 0 ? 32 : lite.precision_bits;
+627
View File
@@ -0,0 +1,627 @@
#include "WarmNodeStore.h"
#if WARM_NODE_COUNT > 0
#include "FSCommon.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "configuration.h"
#include "power/PowerHAL.h"
#include <ErriezCRC32.h>
#include <vector>
#if defined(NRF52840_XXAA)
#include "flash/flash_nrf5x.h"
#define WARM_RING_MAGIC 0x324E5257u // "WRN2" - v2: last_heard low bits carry role + protected category
#define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" - v1: last_heard was a plain timestamp.
// v1 pages are still read on upgrade: we keep each record's identity + public key but
// DISCARD its last_heard (the old timestamp would be misread as role/protected bits).
// Records re-rank and re-learn their role on the next contact. Legacy pages convert to
// v2 naturally as the ring rotates.
// A tombstone is an entry record whose last_heard is all-ones - getTime()
// (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is
// detected via num == 0xFFFFFFFF before last_heard is ever inspected.
#define WARM_RING_TOMBSTONE 0xFFFFFFFFu
#else
// warm.dat layout: this header followed by count packed WarmNodeEntry records.
struct WarmStoreHeader {
uint32_t magic; // WARM_STORE_MAGIC
uint32_t reserved; // 0; kept so the header stays 16 B
uint16_t count; // entries persisted
uint16_t entrySize; // sizeof(WarmNodeEntry), format guard
uint32_t crc; // crc32 over count * entrySize bytes
};
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");
#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" - v2: last_heard low bits carry role + protected category
#define WARM_STORE_MAGIC_V1 \
0x314D5257u // "WRM1" - v1: last_heard was a plain timestamp. On upgrade we keep
// identity + key but discard last_heard, then rewrite as v2.
#ifdef FSCom
static const char *warmFileName = "/prefs/warm.dat";
#endif
#endif // NRF52840_XXAA
static inline bool keyIsSet(const uint8_t key[32])
{
for (int i = 0; i < 32; i++)
if (key[i])
return true;
return false;
}
WarmNodeStore::WarmNodeStore()
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
entries = static_cast<WarmNodeEntry *>(ps_calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
if (!entries) {
LOG_WARN("WarmStore: PSRAM alloc failed, using heap");
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
}
#else
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
#endif
#if defined(NRF52840_XXAA)
memset(pageOf, kNoPage, sizeof(pageOf));
#endif
}
WarmNodeStore::~WarmNodeStore()
{
free(entries); // always malloc-family (calloc / ps_calloc)
entries = nullptr;
}
WarmNodeEntry *WarmNodeStore::find(NodeNum num) const
{
if (!entries || !num)
return nullptr;
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num == num)
return &entries[i];
return nullptr;
}
// Slot placement with the keyed-first admission policy. Shared by absorb()
// and the ring replay, so the policy is applied identically in both paths.
WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8_t *key32)
{
if (!entries || !num)
return nullptr;
const bool candidateKeyed = key32 && keyIsSet(key32);
WarmNodeEntry *slot = find(num);
const bool sameNode = slot != nullptr;
if (!slot) {
// Pick a victim: any empty slot, else the oldest keyless entry, else
// (only for keyed candidates) the oldest keyed entry.
WarmNodeEntry *oldestKeyless = nullptr, *oldestKeyed = nullptr;
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
WarmNodeEntry &e = entries[i];
if (!e.num) {
slot = &e;
break;
}
// Compare on the time bits only - the low metadata bits (role/protected) must
// not perturb LRU victim selection.
if (keyIsSet(e.public_key)) {
if (!oldestKeyed || warmTimeOf(e) < warmTimeOf(*oldestKeyed))
oldestKeyed = &e;
} else {
if (!oldestKeyless || warmTimeOf(e) < warmTimeOf(*oldestKeyless))
oldestKeyless = &e;
}
}
if (!slot)
slot = oldestKeyless ? oldestKeyless : (candidateKeyed ? oldestKeyed : nullptr);
if (!slot)
return nullptr; // store full of keyed entries and the candidate has no key
}
slot->num = num;
slot->last_heard = lastHeard;
if (candidateKeyed)
memcpy(slot->public_key, key32, 32);
else if (!sameNode)
// Repurposing a victim slot for a different node: clear its stale key.
// A keyless refresh of a node already here keeps the key we learned.
memset(slot->public_key, 0, 32);
return slot;
}
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat)
{
// Pack role + protected category into the low bits of last_heard. place() and ring
// replay store the raw word verbatim, so the metadata round-trips through flash.
const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat);
const WarmNodeEntry *slot = place(num, packed, key32);
if (!slot)
return false;
persistEntry(*slot);
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num,
keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat,
(unsigned)count(), (unsigned)capacity());
return true;
}
bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const
{
const WarmNodeEntry *e = find(num);
if (!e)
return false;
role = warmRoleOf(*e);
protectedCat = warmProtOf(*e);
return true;
}
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
{
WarmNodeEntry *e = find(num);
if (!e)
return false;
out = *e;
const int idx = static_cast<int>(e - entries);
memset(e, 0, sizeof(*e));
persistRemove(num, idx);
LOG_MIGRATION("WarmStore take(rehydrate) 0x%08x key=%d (now %u/%u)", (unsigned)num, keyIsSet(out.public_key) ? 1 : 0,
(unsigned)count(), (unsigned)capacity());
return true;
}
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
void WarmNodeStore::dumpToLog(const char *reason) const
{
if (!entries) {
LOG_MIGRATION("WarmStore dump (%s): backend not allocated", reason);
return;
}
LOG_MIGRATION("WarmStore dump (%s): %u live / %u cap ==>", reason, (unsigned)count(), (unsigned)capacity());
unsigned shown = 0;
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
const WarmNodeEntry &e = entries[i];
if (e.num == 0)
continue;
LOG_MIGRATION(" warm[%3u] 0x%08x last_heard=%u key=%d", (unsigned)i, (unsigned)e.num, (unsigned)e.last_heard,
keyIsSet(e.public_key) ? 1 : 0);
shown++;
}
LOG_MIGRATION("WarmStore dump (%s): <== end (%u entries)", reason, shown);
}
#endif // MESHTASTIC_NODEDB_MIGRATION_VERBOSE
bool WarmNodeStore::copyKey(NodeNum num, uint8_t out[32]) const
{
const WarmNodeEntry *e = find(num);
if (!e || !keyIsSet(e->public_key))
return false;
memcpy(out, e->public_key, 32);
return true;
}
bool WarmNodeStore::contains(NodeNum num) const
{
return find(num) != nullptr;
}
void WarmNodeStore::remove(NodeNum num)
{
WarmNodeEntry *e = find(num);
if (e) {
const int idx = static_cast<int>(e - entries);
memset(e, 0, sizeof(*e));
persistRemove(num, idx);
}
}
void WarmNodeStore::clear()
{
if (!entries)
return;
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
#if defined(NRF52840_XXAA)
memset(pageOf, kNoPage, sizeof(pageOf));
#endif
persistClear();
}
size_t WarmNodeStore::count() const
{
size_t n = 0;
if (entries)
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num)
n++;
return n;
}
bool WarmNodeStore::saveIfDirty()
{
if (!dirty)
return true;
bool ok = save();
if (ok)
dirty = false;
return ok;
}
#if defined(NRF52840_XXAA)
// Raw-flash record-ring backend (nRF52840).
// 3 × 4 KB pages below LittleFS. Mutations append 40 B records (entry snapshot,
// or tombstone with last_heard == 0xFFFFFFFF) via the shared flash_nrf5x page
// cache; saveIfDirty() is the durability point. A full page reclaims the oldest
// (stranded live entries re-appended, then erased). Flash access holds spiLock -
// the page cache is shared with InternalFS/LittleFS.
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const
{
flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h));
if (h.seq == 0xFFFFFFFFu)
return false; // erased page
if (h.magic == WARM_RING_MAGIC) {
if (legacy)
*legacy = false;
return true;
}
if (h.magic == WARM_RING_MAGIC_V1) {
if (legacy)
*legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1)
return true;
}
return false;
}
// Caller holds spiLock.
void WarmNodeStore::ringOpenPage(uint8_t page)
{
// Drop any cached state for the page before the real erase, so a later
// cache flush can't resurrect stale bytes.
flash_nrf5x_flush();
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(page));
WarmPageHeader h;
h.magic = WARM_RING_MAGIC;
h.seq = nextSeq++;
flash_nrf5x_write(WARM_FLASH_PAGE_ADDR(page), &h, sizeof(h));
activePage = page;
writeSlot = 0;
}
// Caller holds spiLock. May recurse once via ringAppend if the stranded set
// fills the fresh page exactly - bounded by WARM_NODE_COUNT <= 2*kRecordsPerPage.
void WarmNodeStore::ringRotate()
{
uint8_t target = 0;
if (activePage != kNoPage) {
// Lowest-seq valid page, preferring erased pages; never the active one
uint32_t bestSeq = 0;
bool found = false;
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
if (p == activePage)
continue;
WarmPageHeader h;
if (!ringReadHeader(p, h)) {
target = p; // erased/invalid page: free real estate, take it
found = true;
break;
}
if (!found || static_cast<int32_t>(h.seq - bestSeq) < 0) {
target = p;
bestSeq = h.seq;
found = true;
}
}
}
// Capture live entries stranded in the page we're about to erase
int stranded[WARM_NODE_COUNT] = {};
int nStranded = 0;
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
if (entries[i].num && pageOf[i] == target)
stranded[nStranded++] = static_cast<int>(i);
if (pageOf[i] == target)
pageOf[i] = kNoPage;
}
ringOpenPage(target);
for (int k = 0; k < nStranded; k++)
ringAppend(entries[stranded[k]], stranded[k]);
}
// Caller holds spiLock.
void WarmNodeStore::ringAppend(const WarmNodeEntry &rec, int storeSlot)
{
if (activePage == kNoPage || writeSlot >= kRecordsPerPage)
ringRotate();
const uint32_t addr =
WARM_FLASH_PAGE_ADDR(activePage) + sizeof(WarmPageHeader) + static_cast<uint32_t>(writeSlot) * sizeof(WarmNodeEntry);
flash_nrf5x_write(addr, &rec, sizeof(rec));
writeSlot++;
if (storeSlot >= 0)
pageOf[storeSlot] = activePage;
dirty = true;
}
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
{
concurrency::LockGuard g(spiLock);
ringAppend(e, static_cast<int>(&e - entries));
}
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
{
if (storeSlot >= 0 && storeSlot < static_cast<int>(WARM_NODE_COUNT))
pageOf[storeSlot] = 0xFF;
WarmNodeEntry tomb;
memset(&tomb, 0, sizeof(tomb));
tomb.num = num;
tomb.last_heard = WARM_RING_TOMBSTONE;
concurrency::LockGuard g(spiLock);
ringAppend(tomb, -1);
}
void WarmNodeStore::persistClear()
{
concurrency::LockGuard g(spiLock);
flash_nrf5x_flush();
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++)
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(p));
activePage = 0xFF;
writeSlot = 0;
nextSeq = 1;
dirty = false; // the erased ring already reflects the empty store
}
void WarmNodeStore::load()
{
if (!entries)
return;
concurrency::LockGuard g(spiLock);
// Order valid pages by ascending seq so replay applies oldest first
uint8_t order[WARM_FLASH_PAGES] = {};
uint32_t seqs[WARM_FLASH_PAGES] = {};
bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay
uint8_t nValid = 0;
uint8_t nCorrupt = 0;
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
WarmPageHeader h;
bool legacy = false;
if (!ringReadHeader(p, h, &legacy)) {
// An erased page reads back all-ones; any other magic is a
// partially-written or bit-rotted header we're dropping, so flag it
// rather than silently treating the loss as a clean empty ring.
if (h.magic != 0xFFFFFFFFu)
nCorrupt++;
continue;
}
legacyOf[p] = legacy;
uint8_t pos = nValid;
while (pos > 0 && static_cast<int32_t>(h.seq - seqs[pos - 1]) < 0) {
order[pos] = order[pos - 1];
seqs[pos] = seqs[pos - 1];
pos--;
}
order[pos] = p;
seqs[pos] = h.seq;
nValid++;
}
if (nValid == 0) {
activePage = 0xFF;
writeSlot = 0;
nextSeq = 1;
if (nCorrupt)
LOG_WARN("WarmStore: ring unreadable (%u bad page(s)), empty", nCorrupt);
else
LOG_INFO("WarmStore: ring empty, starting fresh");
return;
}
uint32_t replayed = 0;
uint32_t migrated = 0;
for (uint8_t k = 0; k < nValid; k++) {
const uint8_t p = order[k];
const bool legacy = legacyOf[p];
uint16_t slot = 0;
for (; slot < kRecordsPerPage; slot++) {
WarmNodeEntry rec;
flash_nrf5x_read(&rec, WARM_FLASH_PAGE_ADDR(p) + sizeof(WarmPageHeader) + (uint32_t)slot * sizeof(rec), sizeof(rec));
if (rec.num == 0xFFFFFFFFu)
break; // erased space: end of this page's records (append-only)
if (rec.num == 0)
continue; // unexpected; skip defensively
replayed++;
if (rec.last_heard == WARM_RING_TOMBSTONE) {
WarmNodeEntry *e = find(rec.num);
if (e) {
pageOf[e - entries] = 0xFF;
memset(e, 0, sizeof(*e));
}
} else {
// v1 (legacy) record: keep identity + key, but discard the old timestamp -
// its low bits would otherwise be misread as role/protected metadata.
uint32_t lh = rec.last_heard;
if (legacy) {
lh = 0;
migrated++;
}
const WarmNodeEntry *e = place(rec.num, lh, rec.public_key);
if (e)
pageOf[e - entries] = p;
}
}
if (k == nValid - 1) { // newest page becomes the active head
activePage = p;
writeSlot = slot;
nextSeq = seqs[k] + 1;
// If the head is a v1 page, force the next append to rotate into a fresh v2 page,
// so new (v2) records never land in a page whose header says v1 (which would make
// a later load discard their last_heard - including the role/protected we just set).
if (legacy)
writeSlot = kRecordsPerPage;
}
}
if (nCorrupt)
LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt);
if (migrated)
LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated);
LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(),
activePage, writeSlot);
}
bool WarmNodeStore::save()
{
if (!powerHAL_isPowerLevelSafe()) {
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
return false;
}
concurrency::LockGuard g(spiLock);
flash_nrf5x_flush();
return true;
}
#else // !NRF52840_XXAA --------------------
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
{
(void)e;
dirty = true;
}
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
{
(void)num;
(void)storeSlot;
dirty = true;
}
void WarmNodeStore::persistClear()
{
dirty = true;
}
#ifdef FSCom
// ---- File persistence: /prefs/warm.dat snapshots ----------------------------
// Compact occupied slots to the front of `dst`; returns the count.
static uint16_t packEntries(const WarmNodeEntry *src, WarmNodeEntry *dst)
{
uint16_t n = 0;
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (src[i].num)
dst[n++] = src[i];
return n;
}
void WarmNodeStore::load()
{
if (!entries)
return;
// Clear first - all failure paths below then correctly represent "empty",
// even if load() is called on an already-used instance.
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(warmFileName, FILE_O_READ);
if (!f)
return;
WarmStoreHeader h;
if ((size_t)f.read((uint8_t *)&h, sizeof(h)) != sizeof(h)) {
f.close();
LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName);
return;
}
// v1 (WRM1) is still accepted: same record size, but its last_heard was a plain
// timestamp. We keep identity + key and discard last_heard on load (see below).
const bool legacy = (h.magic == WARM_STORE_MAGIC_V1);
if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
f.close();
LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic,
h.entrySize, h.count);
return;
}
if (h.count) {
const size_t len = (size_t)h.count * sizeof(WarmNodeEntry);
const bool readOk = (size_t)f.read((uint8_t *)entries, len) == len;
f.close();
if (!readOk) {
LOG_WARN("WarmStore: %s entries read failed, starting empty", warmFileName);
return;
}
// CRC covers the bytes as written (v1 still has the old last_heard), so check before migrating.
if (crc32Buffer(entries, len) != h.crc) {
LOG_WARN("WarmStore: %s CRC mismatch, starting empty", warmFileName);
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
return;
}
if (legacy) {
// Migrate v1 → v2: discard the old last_heard (its low bits would be misread as
// role/protected); keep num + public_key. Mark dirty so save() rewrites as v2.
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num)
entries[i].last_heard = 0;
dirty = true;
}
} else {
f.close();
}
LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName,
legacy ? " (v1 migrated: discarded last_heard)" : "");
}
bool WarmNodeStore::save()
{
if (!entries)
return false;
if (!powerHAL_isPowerLevelSafe()) {
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
return false;
}
std::vector<WarmNodeEntry> packed(WARM_NODE_COUNT);
WarmStoreHeader h;
h.magic = WARM_STORE_MAGIC;
h.reserved = 0;
h.count = packEntries(entries, packed.data());
h.entrySize = sizeof(WarmNodeEntry);
h.crc = crc32Buffer(packed.data(), h.count * sizeof(WarmNodeEntry));
// SafeFile already does its own spiLock in its constructor and close().
// Avoid nesting spiLocks, as this will hang until watchdog reset!
{
concurrency::LockGuard g(spiLock);
FSCom.mkdir("/prefs");
}
auto f = SafeFile(warmFileName, false);
{
concurrency::LockGuard g(spiLock);
f.write((const uint8_t *)&h, sizeof(h));
f.write((const uint8_t *)packed.data(), h.count * sizeof(WarmNodeEntry));
}
bool ok = f.close();
if (!ok)
LOG_ERROR("WarmStore: can't write %s", warmFileName);
else
LOG_DEBUG("WarmStore: saved %u warm nodes to %s", h.count, warmFileName);
return ok;
}
#else
void WarmNodeStore::load() {}
bool WarmNodeStore::save()
{
return true;
}
#endif // FSCom
#endif // NRF52840_XXAA
#endif // WARM_NODE_COUNT > 0

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