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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,6 +99,9 @@ class ScanI2C
|
||||
CW2015,
|
||||
SCD30,
|
||||
ADS1115,
|
||||
IIS2MDCTR,
|
||||
ISM330DHCX,
|
||||
SPA06,
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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--) {
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 0–25%
|
||||
float weight2 = 0.35; // Weight for 25–40%
|
||||
float weight3 = 0.20; // Weight for 40–100%
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (~4–8 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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(¬ifyLightSleep);
|
||||
lsEndObserver.observe(¬ifyLightSleepEnd);
|
||||
#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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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{};
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+120
-12
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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 920–925 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user