diff --git a/platformio.ini b/platformio.ini
index 6e0d20d463..a1dd0d310c 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -137,7 +137,7 @@ lib_deps =
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
- https://github.com/meshtastic/device-ui/archive/e1de01e0b3c4a6b149c00e95d59cfb0cca7ad49e.zip
+ https://github.com/meshtastic/device-ui/archive/adfbd3811a53b6aed0649c8d8f078118c042a407.zip
custom_sdkconfig =
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp
index 268cb8211d..2ef5b2ea1c 100644
--- a/src/PowerFSM.cpp
+++ b/src/PowerFSM.cpp
@@ -173,23 +173,25 @@ static void lsIdle()
powerFSM.trigger(EVENT_SERIAL_CONNECTED);
break;
- default:
- // We woke for some other reason (button press, device IRQ interrupt)
-
-#ifdef BUTTON_PIN
- bool pressed = !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN);
-#else
+ case ESP_SLEEP_WAKEUP_GPIO: {
bool pressed = false;
+#if defined(BUTTON_PIN)
+ pressed = !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN);
+#elif defined(KB_INT)
+ // keyboard press (probably) triggered GPIO interrupt
+ pressed = true;
#endif
- if (pressed) { // If we woke because of press, instead generate a PRESS event.
+ if (pressed) {
powerFSM.trigger(EVENT_PRESS);
- } else {
- // Otherwise let the NB state handle the IRQ (and that state will handle stuff like IRQs etc)
- // we lie and say "wake timer" because the interrupt will be handled by the regular IRQ code
- powerFSM.trigger(EVENT_WAKE_TIMER);
}
break;
}
+ default:
+ // Otherwise let the NB state handle the IRQ (and that state will handle stuff like IRQs etc)
+ // we lie and say "wake timer" because the interrupt will be handled by the regular IRQ code
+ powerFSM.trigger(EVENT_WAKE_TIMER);
+ break;
+ }
} else {
// Someone says we can't sleep now, so just save some power by sleeping the CPU for 100ms or so
delay(100);
diff --git a/src/concurrency/Lock.cpp b/src/concurrency/Lock.cpp
index 4596e0edfd..9068cee433 100644
--- a/src/concurrency/Lock.cpp
+++ b/src/concurrency/Lock.cpp
@@ -26,6 +26,11 @@ void Lock::lock()
}
}
+bool Lock::lock(uint32_t timeout)
+{
+ return xSemaphoreTake(handle, pdMS_TO_TICKS(timeout)) == pdTRUE;
+}
+
void Lock::unlock()
{
if (xSemaphoreGive(handle) == false) {
@@ -39,6 +44,11 @@ Lock::~Lock() {}
void Lock::lock() {}
+bool Lock::lock(uint32_t)
+{
+ return true;
+}
+
void Lock::unlock() {}
#endif
diff --git a/src/concurrency/Lock.h b/src/concurrency/Lock.h
index a512481178..342747ae7f 100644
--- a/src/concurrency/Lock.h
+++ b/src/concurrency/Lock.h
@@ -22,6 +22,11 @@ class Lock
// Must not be called from an ISR.
void lock();
+ /// Locks the lock with timeout.
+ //
+ // Must not be called from an ISR.
+ bool lock(uint32_t timeout);
+
// Unlocks the lock.
//
// Must not be called from an ISR.
diff --git a/src/configuration.h b/src/configuration.h
index 1a6550366e..03cb12bf36 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -253,6 +253,7 @@ along with this program. If not, see .
#define BBQ10_KB_ADDR 0x1F
#define MPR121_KB_ADDR 0x5A
#define TCA8418_KB_ADDR 0x34
+#define TSTC8_KB_ADDR 0x6C // STC8H companion-MCU keypad on the ThinkNode-M9
// -----------------------------------------------------------------------------
// SENSOR
@@ -270,6 +271,7 @@ along with this program. If not, see .
#define QMC5883L_ADDR 0x0D
#define HMC5883L_ADDR 0x1E
#define MMC5983MA_ADDR 0x30
+#define QMC6309_ADDR 0x7C
#define SHTC3_ADDR 0x70
#define LPS22HB_ADDR 0x5C
#define LPS22HB_ADDR_ALT 0x5D
diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp
index eb07101010..eff44c114f 100644
--- a/src/detect/ScanI2C.cpp
+++ b/src/detect/ScanI2C.cpp
@@ -31,21 +31,21 @@ ScanI2C::FoundDevice ScanI2C::firstRTC() const
ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
{
- ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MPR121KB, TCA8418KB};
- return firstOfOrNONE(6, types);
+ ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MPR121KB, TCA8418KB, STC8HKB};
+ return firstOfOrNONE(7, types);
}
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
- ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX,
- ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P};
- return firstOfOrNONE(13, types);
+ ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX,
+ ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P, QMI8658};
+ return firstOfOrNONE(14, types);
}
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
{
- ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
- return firstOfOrNONE(2, types);
+ ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR, QMC6309};
+ return firstOfOrNONE(3, types);
}
ScanI2C::FoundDevice ScanI2C::firstAQI() const
diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h
index 6a97370dcb..c36434ae78 100644
--- a/src/detect/ScanI2C.h
+++ b/src/detect/ScanI2C.h
@@ -42,6 +42,7 @@ class ScanI2C
QMC5883L,
HMC5883L,
MMC5983MA,
+ QMC6309,
PMSA003I,
QMA6100P,
MPU6050,
@@ -105,6 +106,7 @@ class ScanI2C
IIS2MDCTR,
ISM330DHCX,
SPA06,
+ STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9)
DS248X,
HM330X
} DeviceType;
diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp
index f0f4671f59..9085763d5c 100644
--- a/src/detect/ScanI2CTwoWire.cpp
+++ b/src/detect/ScanI2CTwoWire.cpp
@@ -444,6 +444,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
type = BBQ10KB;
logFoundDevice("BB Q10", (uint8_t)addr.address);
break;
+ SCAN_SIMPLE_CASE(TSTC8_KB_ADDR, STC8HKB, "STC8H KB", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address);
#ifdef HAS_NCP5623
SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address);
@@ -1093,6 +1094,20 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
foundDevices[addr] = type;
}
}
+
+ // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to
+ // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so
+ // only boards that can actually drive the chip poke this reserved address.
+#if __has_include()
+ addr.address = QMC6309_ADDR;
+ i2cBus->beginTransmission(addr.address);
+ if (i2cBus->endTransmission() == 0 &&
+ getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1) == 0x90 /* QMC6309 chip id */) {
+ deviceAddresses[QMC6309] = addr;
+ foundDevices[addr] = QMC6309;
+ logFoundDevice("QMC6309", (uint8_t)addr.address);
+ }
+#endif
}
void ScanI2CTwoWire::scanPort(I2CPort port)
diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp
index 69000f2fef..73d1d03551 100644
--- a/src/gps/GPS.cpp
+++ b/src/gps/GPS.cpp
@@ -1686,7 +1686,7 @@ GnssModel_t GPS::probe(int serialSpeed)
{"AG3335", "$PAIR021,AG3335", GNSS_MODEL_AG3335},
{"AG3352", "$PAIR021,AG3352", GNSS_MODEL_AG3352},
{"RYS3520", "$PAIR021,REYAX_RYS3520_V2", GNSS_MODEL_AG3352},
- {"UC6580", "UC6580", GNSS_MODEL_UC6580},
+ {"UC6580", "UC6580", GNSS_MODEL_UC6580}
// as L76K is sort of a last ditch effort, we won't attempt to detect it by startup messages for now.
/*{"L76K", "SW=URANUS", GNSS_MODEL_MTK}*/};
GnssModel_t detectedDriver = getProbeResponse(500, passive_detect, serialSpeed);
@@ -1713,8 +1713,10 @@ GnssModel_t GPS::probe(int serialSpeed)
case 1: {
// Unicore UFirebirdII Series: UC6580, UM620, UM621, UM670A, UM680A, or UM681A,or CM121
- std::vector unicore = {
- {"UC6580", "UC6580", GNSS_MODEL_UC6580}, {"UM600", "UM600", GNSS_MODEL_UC6580}, {"CM121", "CM121", GNSS_MODEL_CM121}};
+ std::vector unicore = {{"UC6580", "UC6580", GNSS_MODEL_UC6580},
+ {"UM600", "UM600", GNSS_MODEL_UC6580},
+ {"CM121", "CM121", GNSS_MODEL_CM121},
+ {"CC1167Q", "CC1167Q", GNSS_MODEL_CM121}};
PROBE_FAMILY("Unicore Family", "$PDTINFO", unicore, 500);
currentDelay = 20;
currentStep = 2;
diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp
index a81942abab..fad540cad7 100644
--- a/src/graphics/draw/UIRenderer.cpp
+++ b/src/graphics/draw/UIRenderer.cpp
@@ -23,6 +23,9 @@
#include "graphics/images.h"
#include "main.h"
#include "target_specific.h"
+#ifdef COMPASS_SENSOR_DEBUG
+#include "motion/MotionSensor.h"
+#endif
#include
#include
#include
@@ -1776,6 +1779,26 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
const int *textPos = getTextPositions(display);
const bool compactPanel = graphics::isCompactPanel(display);
+#ifdef COMPASS_SENSOR_DEBUG
+ // Optional raw IMU accel + magnetometer x/y/z readout for on-device axis/sign tuning.
+ {
+ char dbg[40];
+ float sx = 0, sy = 0, sz = 0;
+ uint32_t age = 0;
+ if (MotionSensor::getLatestCompassAccelSample(sx, sy, sz, age))
+ snprintf(dbg, sizeof(dbg), "A %.2f %.2f %.2f", sx, sy, sz);
+ else
+ snprintf(dbg, sizeof(dbg), "A ---");
+ display->drawString(x, textPos[line++], dbg);
+
+ if (MotionSensor::getLatestCompassMagSample(sx, sy, sz, age))
+ snprintf(dbg, sizeof(dbg), "M %.2f %.2f %.2f", sx, sy, sz);
+ else
+ snprintf(dbg, sizeof(dbg), "M ---");
+ display->drawString(x, textPos[line++], dbg);
+ }
+#endif
+
// === First Row: My Location ===
#if HAS_GPS
bool origBold = config.display.heading_bold;
diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp
index cfb23443e4..460b2f86e8 100644
--- a/src/graphics/tftSetup.cpp
+++ b/src/graphics/tftSetup.cpp
@@ -235,10 +235,8 @@ DeviceScreen *deviceScreen = nullptr;
#ifdef ARCH_ESP32
// Get notified when the system is entering light sleep
-CallbackObserver tftSleepObserver =
- CallbackObserver(deviceScreen, &DeviceScreen::prepareSleep);
-CallbackObserver endSleepObserver =
- CallbackObserver(deviceScreen, &DeviceScreen::wakeUp);
+static CallbackObserver *tftSleepObserver = nullptr;
+static CallbackObserver *endSleepObserver = nullptr;
#endif
/**
@@ -277,6 +275,19 @@ class ReentrantSpiLock : public ISpiLock
depth = 1;
}
+ bool lock(uint32_t timeout) override
+ {
+ ThreadId self = currentThread();
+ if (depth && owner == self) {
+ depth++;
+ return true;
+ }
+ bool result = spiLock->lock(timeout);
+ owner = self;
+ depth = 1;
+ return result;
+ }
+
void unlock(void) override
{
if (--depth == 0) {
@@ -413,8 +424,14 @@ void tftSetup(void)
if (deviceScreen) {
#ifdef ARCH_ESP32
- tftSleepObserver.observe(¬ifyLightSleep);
- endSleepObserver.observe(¬ifyLightSleepEnd);
+ if (!tftSleepObserver) {
+ tftSleepObserver = new CallbackObserver(deviceScreen, &DeviceScreen::prepareSleep);
+ }
+ if (!endSleepObserver) {
+ endSleepObserver = new CallbackObserver(deviceScreen, &DeviceScreen::wakeUp);
+ }
+ tftSleepObserver->observe(¬ifyLightSleep);
+ endSleepObserver->observe(¬ifyLightSleepEnd);
xTaskCreatePinnedToCore(tft_task_handler, "tft", TFT_TASK_STACK_SIZE, NULL, 1, NULL, 0);
#elif defined(ARCH_PORTDUINO)
std::thread *tft_task = new std::thread([] { tft_task_handler(); });
diff --git a/src/input/STC8HKeyboard.cpp b/src/input/STC8HKeyboard.cpp
new file mode 100644
index 0000000000..134153593a
--- /dev/null
+++ b/src/input/STC8HKeyboard.cpp
@@ -0,0 +1,155 @@
+#include "STC8HKeyboard.h"
+
+#if defined(ELECROW_ThinkNode_M9)
+#include "cardKbI2cImpl.h"
+
+#include "configuration.h"
+
+// ---------------------------------------------------------------------------
+// STC8H companion-MCU keypad driver (ThinkNode-M9).
+//
+// The original STC8HKeyboard.cpp was lost from the reference source tree, so
+// this was recovered from the linked reference firmware.elf (the .o was an LTO
+// object with no machine code; the final ELF had the real inlined bodies).
+//
+// How the hardware works:
+// - The STC8H raises KB_INT (rising edge, idle-low) when a key is pressed. The ISR
+// latches key_event; is_key_event() just returns that flag.
+// - The pressed key code is read over I2C from register 0x05.
+// - is_key_state() polls KB_INT directly to keep the backlight lit while a
+// key is held.
+// - Battery voltage lives in registers 0x01..0x04, little-endian.
+// - Sleep is requested by writing 0x01 to the STATE register (0x06).
+// - The keypad backlight (KB_LED) and torch (PIN_LED) are plain host GPIOs,
+// not I2C commands.
+// ---------------------------------------------------------------------------
+
+STC8HKeyboard Stc8HKeyBoard;
+
+// ISR latched on each KB_INT rising edge (a key was pressed).
+static void has_key_event()
+{
+ Stc8HKeyBoard.key_event = true;
+ if (cardKbI2cImpl) {
+ cardKbI2cImpl->setIntervalFromNow(0);
+ // runASAP = true;
+ BaseType_t higherWake = 0;
+ concurrency::mainDelay.interruptFromISR(&higherWake);
+ }
+}
+
+void STC8HKeyboard::writeRegister(uint8_t reg, uint8_t val)
+{
+ _pWire->beginTransmission(_I2C_addr);
+ _pWire->write(reg);
+ _pWire->write(val);
+ _pWire->endTransmission();
+}
+
+uint8_t STC8HKeyboard::readRegister(uint8_t reg)
+{
+ _pWire->beginTransmission(_I2C_addr);
+ _pWire->write(reg);
+ if (_pWire->endTransmission(false) != 0)
+ return 0xFF;
+ if (_pWire->requestFrom(_I2C_addr, (uint8_t)1) != 1)
+ return 0xFF;
+ return _pWire->read();
+}
+
+void STC8HKeyboard::begin(uint8_t addr, TwoWire *wire)
+{
+ LOG_DEBUG("STC8HKeyboard::begin() addr=0x%02x", addr);
+ _I2C_addr = addr;
+ _pWire = wire;
+ pinMode(KB_INT, INPUT);
+#ifdef KB_LED
+ pinMode(KB_LED, OUTPUT);
+#endif
+#ifdef PIN_LED
+ pinMode(PIN_LED, OUTPUT);
+#endif
+ attachInterrupt(KB_INT, has_key_event, RISING);
+ _pWire->begin();
+ Keyboard_state = true;
+#ifdef ARCH_ESP32
+ // Detach/reattach the key interrupt around ESP32 light sleep
+ lsObserver.observe(¬ifyLightSleep);
+ lsEndObserver.observe(¬ifyLightSleepEnd);
+#endif
+}
+
+bool STC8HKeyboard::is_Keyboard_begin()
+{
+ return Keyboard_state;
+}
+
+// A key is currently active (KB_INT held); used to wake the keypad backlight.
+bool STC8HKeyboard::is_key_state()
+{
+ return digitalRead(KB_INT);
+}
+
+// A key-press interrupt has been latched since the flag was last cleared.
+bool STC8HKeyboard::is_key_event()
+{
+ return key_event;
+}
+
+uint8_t STC8HKeyboard::bsp_get_key_value()
+{
+ return readRegister(0x01);
+}
+
+// Battery millivolts: registers 0x01..0x04 read little-endian, low 16 bits.
+uint16_t STC8HKeyboard::bsp_get_battery_voltage()
+{
+ if (!Keyboard_state)
+ return 0;
+ uint32_t voltage = 0;
+ for (uint8_t i = 0; i < 4; i++)
+ voltage |= (uint32_t)readRegister(STC8_REG_ADDR_BATTERY + i) << (i * 8);
+ return voltage > 0xFFFF ? 0xFFFF : (uint16_t)voltage;
+}
+
+void STC8HKeyboard::set_keyboard_blight(bool state)
+{
+#ifdef KB_LED
+ digitalWrite(KB_LED, state);
+#else
+ (void)state; // KB_LED pin not defined for this board
+#endif
+}
+
+void STC8HKeyboard::switch_flashlight()
+{
+#ifdef PIN_LED
+ digitalWrite(PIN_LED, !digitalRead(PIN_LED));
+#endif
+ // else: torch pin unresolved on this board (old board used PIN_LED 13,
+ // which the current variant assigns to BATTERY_PIN) -- see variant.h.
+}
+
+void STC8HKeyboard::set_sleep_status(void)
+{
+ writeRegister(STC8_REG_ADDR_STATE, 0x01);
+ _pWire->end();
+}
+
+#ifdef ARCH_ESP32
+// Detach the key interrupt before ESP32 light sleep, so it can't fire while asleep.
+int STC8HKeyboard::beforeLightSleep(void *unused)
+{
+ detachInterrupt(KB_INT);
+ return 0; // Indicates success
+}
+
+// Reattach the key interrupt after waking from light sleep.
+int STC8HKeyboard::afterLightSleep(esp_sleep_wakeup_cause_t cause)
+{
+ attachInterrupt(KB_INT, has_key_event, RISING);
+ return 0; // Indicates success
+}
+#endif
+
+#endif // ELECROW_ThinkNode_M9
diff --git a/src/input/STC8HKeyboard.h b/src/input/STC8HKeyboard.h
new file mode 100644
index 0000000000..7de3b3d281
--- /dev/null
+++ b/src/input/STC8HKeyboard.h
@@ -0,0 +1,74 @@
+#pragma once
+#ifndef _STC8H_KEYBOARD_H_
+#define _STC8H_KEYBOARD_H_
+
+#include "configuration.h"
+#include "kbI2cBase.h"
+#include
+#if defined(ELECROW_ThinkNode_M9)
+
+#ifdef ARCH_ESP32
+#include "sleep.h" // notifyLightSleep / notifyLightSleepEnd + esp_sleep_wakeup_cause_t
+#endif
+
+// Registers exposed by the STC8H companion MCU over I2C.
+#define STC8_REG_ADDR_BATTERY 0x01
+#define STC8_REG_ADDR_MATRIX_KEY 0x05
+#define STC8_REG_ADDR_STATE 0x06
+
+class STC8HKeyboard
+{
+ public:
+ STC8HKeyboard(){};
+
+ void begin(uint8_t addr, TwoWire *wire);
+
+ void set_sleep_status(void);
+
+ uint16_t bsp_get_battery_voltage();
+
+ bool is_key_event();
+
+ bool is_Keyboard_begin();
+
+ bool is_key_state();
+
+ uint8_t bsp_get_key_value();
+
+ void set_keyboard_blight(bool state);
+
+ void switch_flashlight();
+
+ uint8_t readRegister(uint8_t reg);
+
+ bool key_event;
+
+#ifdef ARCH_ESP32
+ // Detach/reattach the KB_INT interrupt around ESP32 light sleep, so the
+ // companion MCU's key interrupt can't fire spuriously while asleep.
+ int beforeLightSleep(void *unused);
+ int afterLightSleep(esp_sleep_wakeup_cause_t cause);
+#endif
+
+ private:
+ void writeRegister(uint8_t reg, uint8_t val);
+
+ uint8_t _I2C_addr = TSTC8_KB_ADDR;
+
+ TwoWire *_pWire = &Wire;
+
+ bool Keyboard_state = false;
+
+#ifdef ARCH_ESP32
+ // Get notified when light sleep begins and ends (mirrors TwoButton / Power)
+ CallbackObserver lsObserver =
+ CallbackObserver(this, &STC8HKeyboard::beforeLightSleep);
+ CallbackObserver lsEndObserver =
+ CallbackObserver(this, &STC8HKeyboard::afterLightSleep);
+#endif
+};
+
+extern STC8HKeyboard Stc8HKeyBoard;
+
+#endif
+#endif
diff --git a/src/input/cardKbI2cImpl.cpp b/src/input/cardKbI2cImpl.cpp
index cb03eb4ff6..dd86607c05 100644
--- a/src/input/cardKbI2cImpl.cpp
+++ b/src/input/cardKbI2cImpl.cpp
@@ -51,6 +51,10 @@ void CardKbI2cImpl::init()
// assign an arbitrary value to distinguish from other models
kb_model = 0x84;
break;
+ case ScanI2C::DeviceType::STC8HKB:
+ // assign an arbitrary value to distinguish from other models
+ kb_model = 0x12;
+ break;
default:
// use this as default since it's also just zero
LOG_WARN("kb_info.type is unknown(0x%02x), setting kb_model=0x00", kb_info.type);
diff --git a/src/input/kbI2cBase.cpp b/src/input/kbI2cBase.cpp
index b786774605..d4851c7f65 100644
--- a/src/input/kbI2cBase.cpp
+++ b/src/input/kbI2cBase.cpp
@@ -15,6 +15,12 @@
#include "TCA8418Keyboard.h"
#endif
+#if defined(ELECROW_ThinkNode_M9)
+#include "STC8HKeyboard.h"
+#include "graphics/Screen.h" // for the global `screen` + FrameFocus
+#include "graphics/draw/NotificationRenderer.h" // for resetBanner()
+#endif
+
extern ScanI2C::DeviceAddress cardkb_found;
extern uint8_t kb_model;
@@ -64,6 +70,11 @@ int32_t KbI2cBase::runOnce()
// resolved via the scanner: WIRE1 may be a bridged bus rather
// than the local Wire1 (e.g. SenseCAP Indicator)
i2cBus = ScanI2CTwoWire::fetchI2CBus(cardkb_found);
+#if defined(ELECROW_ThinkNode_M9)
+ if (cardkb_found.address == TSTC8_KB_ADDR) {
+ Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire1);
+ }
+#endif
if (cardkb_found.address == BBQ10_KB_ADDR) {
Q10keyboard.begin(BBQ10_KB_ADDR, i2cBus);
Q10keyboard.setBacklight(0);
@@ -79,6 +90,11 @@ int32_t KbI2cBase::runOnce()
case ScanI2C::WIRE:
LOG_DEBUG("Use I2C Bus 0 (the first one)");
i2cBus = &Wire;
+#if defined(ELECROW_ThinkNode_M9)
+ if (cardkb_found.address == TSTC8_KB_ADDR) {
+ Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire);
+ }
+#endif
if (cardkb_found.address == BBQ10_KB_ADDR) {
Q10keyboard.begin(BBQ10_KB_ADDR, &Wire);
Q10keyboard.setBacklight(0);
@@ -546,6 +562,104 @@ int32_t KbI2cBase::runOnce()
}
break;
}
+#if defined(ELECROW_ThinkNode_M9)
+ case 0x12: { // STC8H companion-MCU keypad (ThinkNode-M9)
+ Stc8HKeyBoard.key_event = false;
+ InputEvent e = {};
+ e.inputEvent = INPUT_BROKER_NONE;
+ e.source = this->_originName;
+ uint8_t c = Stc8HKeyBoard.bsp_get_key_value(); // unsigned so the 0x8x/0xbx codes match
+ switch (c) {
+ case 0x81: // Mute
+ e.inputEvent = INPUT_BROKER_ANYKEY;
+ e.kbchar = INPUT_BROKER_MSG_MUTE_TOGGLE;
+ break;
+ case 0x82: // Home
+ e.inputEvent = INPUT_BROKER_ANYKEY;
+ graphics::NotificationRenderer::resetBanner();
+ // TODO(M9): also reset CannedMessage/PresetMessage state once those modules are ported
+ if (screen)
+ screen->setFrames(graphics::Screen::FOCUS_FAULT);
+ break;
+ case 0x83: // Time
+ e.inputEvent = INPUT_BROKER_ANYKEY;
+ graphics::NotificationRenderer::resetBanner();
+ // TODO(M9): also reset CannedMessage/PresetMessage state once those modules are ported
+ if (screen)
+ screen->setFrames(graphics::Screen::FOCUS_CLOCK);
+ break;
+ case 0x84:
+ e.inputEvent = INPUT_BROKER_GPS_TOGGLE;
+ Stc8HKeyBoard.switch_flashlight();
+ break;
+ case 0x85: // FM
+ e.inputEvent = INPUT_BROKER_SEND_PING;
+ e.kbchar = 0;
+ break;
+ case 0x86: // FM (long press)
+ e.inputEvent = INPUT_BROKER_CANCEL;
+ e.kbchar = 0;
+ break;
+ case 0x87: // Preset
+ graphics::NotificationRenderer::resetBanner();
+ // TODO(M9): also reset CannedMessage state once that module is ported
+ e.inputEvent = INPUT_BROKER_SELECT_LONG;
+ e.kbchar = 0;
+ break;
+ case 0xb5: // Up
+ e.inputEvent = INPUT_BROKER_UP;
+ e.kbchar = 0;
+ break;
+ case 0xb4: // Left
+ e.inputEvent = INPUT_BROKER_LEFT;
+ e.kbchar = 0;
+ break;
+ case 0xb6: // Down
+ e.inputEvent = INPUT_BROKER_DOWN;
+ e.kbchar = 0;
+ break;
+ case 0xb7: // Right
+ e.inputEvent = INPUT_BROKER_RIGHT;
+ e.kbchar = 0;
+ break;
+ case 0x20: // Space
+ e.inputEvent = INPUT_BROKER_ANYKEY;
+ e.kbchar = 0x20;
+ break;
+ case 0x0d: // Enter
+ e.inputEvent = INPUT_BROKER_SELECT;
+ e.kbchar = 0;
+ break;
+ case 0x08: // Del
+ e.inputEvent = INPUT_BROKER_BACK;
+ e.kbchar = 0;
+ break;
+ case 0x89: // Del (long press)
+ e.inputEvent = INPUT_BROKER_BACK;
+ e.kbchar = 0;
+ break;
+ case 0x88: // Invalid key value
+ e.inputEvent = INPUT_BROKER_ANYKEY;
+ e.kbchar = 0;
+ break;
+ default: // all other keys (printable ASCII)
+ if ((c >= 0x20) && (c <= 0x7F)) {
+ e.inputEvent = INPUT_BROKER_ANYKEY;
+ e.kbchar = c;
+ } else {
+ e.inputEvent = INPUT_BROKER_NONE;
+ e.kbchar = 0;
+ }
+ break;
+ }
+ if (e.inputEvent != INPUT_BROKER_NONE) {
+ // LOG_DEBUG("STC8H companion-MCU keypad key event: 0x%02x", c);
+ this->notifyObservers(&e);
+ }
+
+ break;
+ }
+#endif
default:
LOG_WARN("Unknown kb_model 0x%02x", kb_model);
}
diff --git a/src/main.cpp b/src/main.cpp
index 6b192aa049..df87cfcdeb 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -761,6 +761,10 @@ void setup()
// assign an arbitrary value to distinguish from other models
kb_model = 0x84;
break;
+ case ScanI2C::DeviceType::STC8HKB:
+ // assign an arbitrary value to distinguish from other models
+ kb_model = 0x12;
+ break;
default:
// use this as default since it's also just zero
LOG_WARN("kb_info.type unknown(0x%02x), set kb_model=0x00", kb_info.type);
diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp
index 715daff1b9..41d570f3e1 100644
--- a/src/mesh/NodeDB.cpp
+++ b/src/mesh/NodeDB.cpp
@@ -1032,7 +1032,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32);
#if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \
- defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2)) && \
+ defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2) || \
+ defined(ELECROW_ThinkNode_M9)) && \
HAS_TFT
// switch BT off by default; use TFT programming mode or hotkey to enable
config.bluetooth.enabled = false;
@@ -1264,7 +1265,10 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.external_notification.output_ms = 1000;
#endif
-#if defined(PIN_VIBRATION)
+#if HAS_TFT
+ if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs)
+ moduleConfig.external_notification.nag_timeout = 0;
+#elif defined(PIN_VIBRATION)
moduleConfig.external_notification.nag_timeout = 2;
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || \
defined(HAS_I2S_SPEAKER_NRF52)
@@ -1276,12 +1280,6 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.external_notification.enabled = true;
moduleConfig.external_notification.use_i2s_as_buzzer = true;
moduleConfig.external_notification.alert_message_buzzer = true;
-#if HAS_TFT
- if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs)
- moduleConfig.external_notification.nag_timeout = 0;
-#else
- moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
-#endif // HAS_TFT
#endif // HAS_I2S
#ifdef NANO_G2_ULTRA
diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h
index 0bcb504fbb..63009aecc7 100755
--- a/src/motion/AccelerometerThread.h
+++ b/src/motion/AccelerometerThread.h
@@ -21,6 +21,7 @@
#include "LSM6DS3Sensor.h"
#include "MPU6050Sensor.h"
#include "MotionSensor.h"
+#include "QMI8658Sensor.h"
#include
#ifdef HAS_QMA6100P
@@ -146,6 +147,11 @@ class AccelerometerThread : public concurrency::OSThread
case ScanI2C::DeviceType::QMA6100P:
sensor.reset(new QMA6100PSensor(device));
break;
+#endif
+#if __has_include()
+ case ScanI2C::DeviceType::QMI8658:
+ sensor.reset(new QMI8658Sensor(device));
+ break;
#endif
default:
disable();
diff --git a/src/motion/MagnetometerThread.h b/src/motion/MagnetometerThread.h
index 8185f296aa..4a3d7d69a4 100644
--- a/src/motion/MagnetometerThread.h
+++ b/src/motion/MagnetometerThread.h
@@ -9,6 +9,7 @@
#include "../concurrency/OSThread.h"
#include "MMC5983MASensor.h"
#include "MotionSensor.h"
+#include "QMC6309Sensor.h"
#include
@@ -73,6 +74,11 @@ class MagnetometerThread : public concurrency::OSThread
case ScanI2C::DeviceType::MMC5983MA:
sensor.reset(new MMC5983MASensor(device));
break;
+#endif
+#if __has_include()
+ case ScanI2C::DeviceType::QMC6309:
+ sensor.reset(new QMC6309Sensor(device));
+ break;
#endif
default:
disable();
diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp
index 6cbe8e21db..e6331ea857 100755
--- a/src/motion/MotionSensor.cpp
+++ b/src/motion/MotionSensor.cpp
@@ -42,6 +42,9 @@ struct CompassAccelSample {
concurrency::Lock latestCompassAccelLock;
CompassAccelSample latestCompassAccelSample;
+
+concurrency::Lock latestCompassMagLock;
+CompassAccelSample latestCompassMagSample;
} // namespace
// screen is defined in main.cpp
@@ -245,6 +248,35 @@ bool MotionSensor::getLatestCompassAccelSample(float &x, float &y, float &z, uin
return true;
}
+void MotionSensor::publishCompassMagSample(float x, float y, float z)
+{
+ concurrency::LockGuard guard(&latestCompassMagLock);
+ latestCompassMagSample.x = x;
+ latestCompassMagSample.y = y;
+ latestCompassMagSample.z = z;
+ latestCompassMagSample.sampledAtMs = millis();
+ latestCompassMagSample.valid = true;
+}
+
+bool MotionSensor::getLatestCompassMagSample(float &x, float &y, float &z, uint32_t &ageMs)
+{
+ uint32_t sampledAtMs = 0;
+ {
+ concurrency::LockGuard guard(&latestCompassMagLock);
+ if (!latestCompassMagSample.valid) {
+ return false;
+ }
+
+ x = latestCompassMagSample.x;
+ y = latestCompassMagSample.y;
+ z = latestCompassMagSample.z;
+ sampledAtMs = latestCompassMagSample.sampledAtMs;
+ }
+
+ ageMs = millis() - sampledAtMs;
+ return true;
+}
+
#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h
index 38876fb776..ef84e1b19b 100755
--- a/src/motion/MotionSensor.h
+++ b/src/motion/MotionSensor.h
@@ -42,6 +42,11 @@ class MotionSensor
virtual void calibrate(uint16_t forSeconds){};
+ // Latest samples published by the compass-fusion drivers (accel from the IMU, mag from the magnetometer).
+ // Public so an optional on-screen sensor debug readout can read them. Return false if nothing published yet.
+ static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs);
+ static bool getLatestCompassMagSample(float &x, float &y, float &z, uint32_t &ageMs);
+
// True if this sensor produces the compass heading (screen->setHeading()) in runOnce().
// Combined accel+magnetometer parts (e.g. BMX160, ICM20948) and standalone magnetometers
// handled by the accelerometer thread (e.g. BMM150) override this. Used to avoid halting
@@ -74,7 +79,7 @@ class MotionSensor
float &lowestY, float &highestZ, float &lowestZ);
static float applyCompassOrientation(float heading);
static void publishCompassAccelSample(float x, float y, float z);
- static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs);
+ static void publishCompassMagSample(float x, float y, float z);
ScanI2C::FoundDevice device;
diff --git a/src/motion/QMC6309Sensor.cpp b/src/motion/QMC6309Sensor.cpp
new file mode 100644
index 0000000000..bdf82878b2
--- /dev/null
+++ b/src/motion/QMC6309Sensor.cpp
@@ -0,0 +1,152 @@
+#include "QMC6309Sensor.h"
+
+#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include()
+
+#include "Fusion/Fusion.h"
+#include "detect/ScanI2CTwoWire.h"
+#include
+
+#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
+extern std::unique_ptr screen;
+#endif
+
+static constexpr int32_t QMC6309_UPDATE_INTERVAL_MS = 20;
+// Heading offset/flip below is a starting point copied from the MMC5983MA path; it is orientation-specific
+// and must be verified/tuned against known North on real M9 hardware (see plan).
+static constexpr float QMC6309_HEADING_OFFSET_DEG = 180.0f;
+static constexpr uint32_t QMC6309_ACCEL_STALE_MS = 300;
+static constexpr float QMC6309_MIN_AXIS_RADIUS = 1e-4f;
+
+QMC6309Sensor::QMC6309Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}
+
+bool QMC6309Sensor::init()
+{
+ LOG_DEBUG("QMC6309 begin on addr 0x%02X (port=%d)", device.address.address, device.address.port);
+ TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address);
+
+ if (!sensor.begin(*wire, deviceAddress())) {
+ LOG_DEBUG("QMC6309 init error");
+ return false;
+ }
+
+ sensor.reset();
+
+ // 8 Gauss full-scale easily covers Earth's ~0.5 G field; OSR_8 for low noise. Tunable.
+ if (!sensor.configMagnetometer(OperationMode::CONTINUOUS_MEASUREMENT, MagFullScaleRange::FS_8G, 100.0f,
+ MagOverSampleRatio::OSR_8)) {
+ LOG_DEBUG("QMC6309 config failed");
+ return false;
+ }
+
+ loadMagnetometerCalibration(compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
+ LOG_DEBUG("QMC6309 init ok");
+ LOG_DEBUG("QMC6309 calibration extrema: X=(%.3f, %.3f), Y=(%.3f, %.3f), Z=(%.3f, %.3f)", lowestX, highestX, lowestY, highestY,
+ lowestZ, highestZ);
+ return true;
+}
+
+bool QMC6309Sensor::readMagnetometer(float &xGauss, float &yGauss, float &zGauss)
+{
+ MagnetometerData data;
+ if (!sensor.readData(data)) {
+ return false;
+ }
+
+ // magnetic_field is already scaled to Gauss by the driver.
+ xGauss = data.magnetic_field.x;
+ yGauss = data.magnetic_field.y;
+ zGauss = data.magnetic_field.z;
+ return true;
+}
+
+int32_t QMC6309Sensor::runOnce()
+{
+ float magX = 0, magY = 0, magZ = 0;
+ if (!readMagnetometer(magX, magY, magZ)) {
+ return QMC6309_UPDATE_INTERVAL_MS;
+ }
+
+#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
+ if (doCalibration) {
+ beginCalibrationDisplay(showingScreen);
+ updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
+ finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ,
+ lowestZ);
+ }
+#endif
+
+ // Hard-iron bias removal.
+ magX -= (highestX + lowestX) * 0.5f;
+ magY -= (highestY + lowestY) * 0.5f;
+ magZ -= (highestZ + lowestZ) * 0.5f;
+ // LOG_WARN("QMC6309 extrema=(%.3f, %.3f, %.3f) to (%.3f, %.3f, %.3f)",
+ // lowestX, lowestY, lowestZ, highestX, highestY, highestZ);
+
+ // Soft-iron diagonal scaling from calibration extrema.
+ const float radiusX = (highestX - lowestX) * 0.5f;
+ const float radiusY = (highestY - lowestY) * 0.5f;
+ const float radiusZ = (highestZ - lowestZ) * 0.5f;
+ const float avgRadius = (radiusX + radiusY + radiusZ) / 3.0f;
+ // magX *= (radiusX > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusX) : 1.0f;
+ // magY *= (radiusY > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusY) : 1.0f;
+ // magZ *= (radiusZ > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusZ) : 1.0f;
+
+ // Publish the calibrated magnetometer values (hard/soft-iron applied) for the optional on-screen debug readout.
+ publishCompassMagSample(magX, magY, magZ);
+
+#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
+ float heading;
+ float accelX = 0.0f;
+ float accelY = 0.0f;
+ float accelZ = 0.0f;
+ uint32_t accelAgeMs = 0;
+
+ // Fuse with the latest accelerometer sample (published by the QMI8658 driver) for tilt compensation.
+ if (getLatestCompassAccelSample(accelX, accelY, accelZ, accelAgeMs) && accelAgeMs <= QMC6309_ACCEL_STALE_MS) {
+ FusionVector ga = {.axis = {accelX, accelY, accelZ}};
+ FusionVector ma = {.axis = {magX, magY, magZ}};
+ // if (config.display.compass_orientation > meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270) {
+ // ma = FusionAxesSwap(ma, FusionAxesAlignmentNXNYPZ);
+ // ga = FusionAxesSwap(ga, FusionAxesAlignmentNXNYPZ);
+ //}
+ // LOG_WARN("QMC6309 accel age %ums, ga=(%.3f, %.3f, %.3f), ma=(%.3f, %.3f, %.3f)", accelAgeMs, ga.axis.x, ga.axis.y,
+ // ga.axis.z, ma.axis.x, ma.axis.y, ma.axis.z);
+ heading = FusionCompass(ga, ma, FusionConventionNed);
+ if (ga.axis.z > 0.0f)
+ heading = 360.0f - heading;
+
+ } else {
+ heading = atan2f(-magY, magX) * RAD_TO_DEG;
+ }
+
+ if (heading >= 360.0f)
+ heading -= 360.0f;
+ else if (heading < 0.0f)
+ heading += 360.0f;
+
+ heading = applyCompassOrientation(heading);
+ if (screen)
+ screen->setHeading(heading);
+#endif
+
+ return QMC6309_UPDATE_INTERVAL_MS;
+}
+
+void QMC6309Sensor::calibrate(uint16_t forSeconds)
+{
+#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
+ float xGauss = 0.0f;
+ float yGauss = 0.0f;
+ float zGauss = 0.0f;
+
+ LOG_DEBUG("QMC6309 calibration started for %is", forSeconds);
+ if (readMagnetometer(xGauss, yGauss, zGauss)) {
+ seedCalibrationExtrema(xGauss, yGauss, zGauss, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
+ } else {
+ seedCalibrationExtrema(0.0f, 0.0f, 0.0f, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
+ }
+ startCalibrationWindow(forSeconds);
+#endif
+}
+
+#endif
diff --git a/src/motion/QMC6309Sensor.h b/src/motion/QMC6309Sensor.h
new file mode 100644
index 0000000000..c457f35269
--- /dev/null
+++ b/src/motion/QMC6309Sensor.h
@@ -0,0 +1,40 @@
+#pragma once
+#ifndef _QMC6309_SENSOR_H_
+#define _QMC6309_SENSOR_H_
+
+#include "MotionSensor.h"
+
+#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include()
+
+// SensorQMC6309.hpp (SensorLib 0.4.1) references the isBitSet() macro in an inline method but never includes
+// SensorLib.h where it is defined. Define it here (guarded) so the header compiles regardless of include order
+// (pulling in SensorLib.h is unreliable - its #pragma once can already be tripped by an in-progress include).
+#ifndef isBitSet
+#define isBitSet(value, bit) (((value) & (1UL << (bit))) == (1UL << (bit)))
+#endif
+#include
+
+class QMC6309Sensor : public MotionSensor
+{
+ private:
+ SensorQMC6309 sensor;
+ bool showingScreen = false;
+ static constexpr const char *compassCalibrationFileName = "/prefs/compass_qmc6309.dat";
+#ifdef ELECROW_ThinkNode_M9
+ float highestX = -5.548, lowestX = -6.530, highestY = -6.638, lowestY = -7.637, highestZ = -6.676, lowestZ = -7.633;
+#else
+ float highestX = 0, lowestX = 0, highestY = 0, lowestY = 0, highestZ = 0, lowestZ = 0;
+#endif
+
+ bool readMagnetometer(float &xGauss, float &yGauss, float &zGauss);
+
+ public:
+ explicit QMC6309Sensor(ScanI2C::FoundDevice foundDevice);
+ virtual bool init() override;
+ virtual int32_t runOnce() override;
+ virtual void calibrate(uint16_t forSeconds) override;
+};
+
+#endif
+
+#endif
diff --git a/src/motion/QMI8658Sensor.cpp b/src/motion/QMI8658Sensor.cpp
new file mode 100644
index 0000000000..e55ffc26f7
--- /dev/null
+++ b/src/motion/QMI8658Sensor.cpp
@@ -0,0 +1,88 @@
+#include "QMI8658Sensor.h"
+
+#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include()
+
+#include "NodeDB.h"
+#include "detect/ScanI2CTwoWire.h"
+#include
+
+// Accelerometer configuration. 2G full-scale gives the best gravity resolution for the tilt
+// compensation that the (future) separate compass module will apply to these samples.
+static constexpr SensorQMI8658::AccelRange QMI8658_ACCEL_RANGE = SensorQMI8658::ACC_RANGE_2G;
+static constexpr SensorQMI8658::AccelODR QMI8658_ACCEL_ODR = SensorQMI8658::ACC_ODR_125Hz;
+
+// Any-motion slope threshold (in mg) used to wake the screen. Tunable: raise to reduce false wakes,
+// lower to make it more sensitive. 200mg (~0.2g) requires a deliberate movement.
+static constexpr float QMI8658_ANY_MOTION_THRESHOLD_MG = 200.0f;
+static constexpr uint8_t QMI8658_ANY_MOTION_WINDOW = 1;
+
+// Optional board-defined rotation (degrees) applied to the accel X/Y before publishing to the compass
+// fusion path, mirroring the ICM42607P driver. Defaults to no rotation.
+static constexpr float QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE =
+#ifdef QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG
+ QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG;
+#else
+ 0.0f;
+#endif
+
+QMI8658Sensor::QMI8658Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}
+
+bool QMI8658Sensor::init()
+{
+ LOG_DEBUG("QMI8658 begin on addr 0x%02X (port=%d)", deviceAddress(), devicePort());
+ TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address);
+
+ if (!sensor.begin(*wire, deviceAddress())) {
+ LOG_DEBUG("QMI8658 init failed");
+ return false;
+ }
+
+ sensor.configAccelerometer(QMI8658_ACCEL_RANGE, QMI8658_ACCEL_ODR, SensorQMI8658::LPF_MODE_0);
+ sensor.enableAccelerometer();
+
+ // Configure the on-chip any-motion engine so we can wake the screen without a dedicated interrupt pin.
+ // configMotion() runs alongside normal accel data output (unlike Wake-on-Motion, which halts data), so
+ // we keep publishing samples for compass fusion while still detecting motion.
+ wakeOnMotion = config.display.wake_on_tap_or_motion;
+ if (wakeOnMotion) {
+ const uint8_t modeCtrl = SensorQMI8658::ANY_MOTION_EN_X | SensorQMI8658::ANY_MOTION_EN_Y | SensorQMI8658::ANY_MOTION_EN_Z;
+ // No-motion detection is left disabled (unreliable per the SensorLib example); its thresholds/windows
+ // are still required arguments but are ignored when the mode bits above are clear.
+ sensor.configMotion(modeCtrl, QMI8658_ANY_MOTION_THRESHOLD_MG, QMI8658_ANY_MOTION_THRESHOLD_MG,
+ QMI8658_ANY_MOTION_THRESHOLD_MG, QMI8658_ANY_MOTION_WINDOW, /*NoMotion X/Y/Z*/ 0.1f, 0.1f, 0.1f,
+ /*NoMotionWindow*/ 1, /*SigMotionWaitWindow*/ 1, /*SigMotionConfirmWindow*/ 1);
+ sensor.enableMotionDetect();
+ }
+
+ LOG_DEBUG("QMI8658 init ok");
+ return true;
+}
+
+int32_t QMI8658Sensor::runOnce()
+{
+ float ax, ay, az;
+ if (sensor.getAccelerometer(ax, ay, az)) {
+ if (QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE != 0.0f) {
+ static const float rotRad = QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE * DEG_TO_RAD;
+ static const float cosTheta = cosf(rotRad);
+ static const float sinTheta = sinf(rotRad);
+ const float rotatedX = (ax * cosTheta) - (ay * sinTheta);
+ const float rotatedY = (ax * sinTheta) + (ay * cosTheta);
+ ax = rotatedX;
+ ay = rotatedY;
+ }
+
+ // Match the accel sign convention used by the other FusionCompass sensor paths (e.g. ICM42607P).
+ // The final handedness must be verified against the QMI8658 datasheet and real calibration once the
+ // separate compass module is wired up; do not hand-tune the signs before then.
+ publishCompassAccelSample(ax, ay, az);
+ }
+
+ if (wakeOnMotion && (sensor.getStatusRegister() & SensorQMI8658::EVENT_ANY_MOTION)) {
+ wakeScreen();
+ }
+
+ return MOTION_SENSOR_CHECK_INTERVAL_MS;
+}
+
+#endif
diff --git a/src/motion/QMI8658Sensor.h b/src/motion/QMI8658Sensor.h
new file mode 100644
index 0000000000..6aa310132f
--- /dev/null
+++ b/src/motion/QMI8658Sensor.h
@@ -0,0 +1,25 @@
+#pragma once
+#ifndef _QMI8658_SENSOR_H_
+#define _QMI8658_SENSOR_H_
+
+#include "MotionSensor.h"
+
+#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include()
+
+#include
+
+class QMI8658Sensor : public MotionSensor
+{
+ private:
+ SensorQMI8658 sensor;
+ bool wakeOnMotion = false;
+
+ public:
+ explicit QMI8658Sensor(ScanI2C::FoundDevice foundDevice);
+ virtual bool init() override;
+ virtual int32_t runOnce() override;
+};
+
+#endif
+
+#endif
diff --git a/src/sleep.cpp b/src/sleep.cpp
index c5d469b42a..0a6a379788 100644
--- a/src/sleep.cpp
+++ b/src/sleep.cpp
@@ -453,8 +453,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r
gpio_wakeup_enable((gpio_num_t)ROTARY_PRESS, GPIO_INTR_LOW_LEVEL);
#endif
#ifdef KB_INT
+#if KB_INT_WAKE_ON_HIGH
+ gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_HIGH_LEVEL);
+#else
gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_LOW_LEVEL);
-#endif
+#endif // KB_INT_WAKE_ON_HIGH
+#endif // KB_INT
#ifdef BOARD_PCA9535_INT
// Side-key interrupt line from PCA9535 expander (active low).
gpio_wakeup_enable((gpio_num_t)BOARD_PCA9535_INT, GPIO_INTR_LOW_LEVEL);
diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h b/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h
new file mode 100644
index 0000000000..0099711948
--- /dev/null
+++ b/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h
@@ -0,0 +1,20 @@
+#ifndef Pins_Arduino_h
+#define Pins_Arduino_h
+
+#include
+
+#define USB_VID 0x303a
+#define USB_PID 0x1001
+
+static const uint8_t TX = 43;
+static const uint8_t RX = 44;
+
+static const uint8_t SDA = 20;
+static const uint8_t SCL = 21;
+
+static const uint8_t SS = 39;
+static const uint8_t MOSI = 47;
+static const uint8_t MISO = 38;
+static const uint8_t SCK = 40;
+
+#endif
\ No newline at end of file
diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini
new file mode 100644
index 0000000000..fbd595dee9
--- /dev/null
+++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini
@@ -0,0 +1,99 @@
+[thinknode_m9_base]
+custom_meshtastic_hw_model = 131
+custom_meshtastic_hw_model_slug = ELECROW_ThinkNode_M9
+custom_meshtastic_architecture = esp32-s3
+custom_meshtastic_actively_supported = true
+custom_meshtastic_support_level = 1
+custom_meshtastic_display_name = ThinkNode M9
+custom_meshtastic_images = thinknode_m9.svg
+custom_meshtastic_tags = Elecrow
+custom_meshtastic_requires_dfu = false
+custom_meshtastic_partition_scheme = 16MB
+custom_meshtastic_has_mui = true
+
+extends = esp32s3_base
+board = crowpanel
+board_level = release
+board_build.partitions = default_16MB.csv
+upload_protocol = esptool
+build_src_filter =
+ ${esp32s3_base.build_src_filter}
+ +<../variants/esp32s3/ELECROW-ThinkNode-M9>
+build_flags =
+ ${esp32s3_base.build_flags}
+ -I variants/esp32s3/ELECROW-ThinkNode-M9
+ -D ELECROW_ThinkNode_M9
+ -D BOARD_HAS_PSRAM
+ -D HAS_SDCARD=1
+ -D SDCARD_CS=48
+ -D SDCARD_USE_SPI1
+ -D SDCARD_SHARE_SPI
+ -D SDCARD_INIT_SPI
+ -D SD_SPI_FREQUENCY=75000000U
+ -D HAS_SCREEN=1
+; -D COMPASS_SENSOR_DEBUG=1
+lib_deps = ${esp32s3_base.lib_deps}
+ # renovate: datasource=custom depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
+ lovyan03/LovyanGFX@1.2.26
+ # renovate: datasource=custom depName=SensorLib packageName=lewisxhe/library/SensorLib
+ lewisxhe/SensorLib@0.4.1
+
+[env:thinknode_m9]
+extends = thinknode_m9_base
+build_flags =
+ ${thinknode_m9_base.build_flags}
+
+[env:thinknode_m9-tft]
+extends = thinknode_m9_base
+build_flags =
+ ${thinknode_m9_base.build_flags}
+ -D RADIOLIB_SPI_PARANOID=0
+ -D CONFIG_DISABLE_HAL_LOCKS=1
+ -D HAS_TFT=1
+ -D USE_PACKET_API
+ -D USE_PIN_BUZZER=PIN_BUZZER
+ -D LV_LVGL_H_INCLUDE_SIMPLE
+ -D LV_CONF_INCLUDE_SIMPLE
+ -D LV_COMP_CONF_INCLUDE_SIMPLE
+ -D LV_USE_SYSMON=0
+ -D LV_USE_PROFILER=0
+ -D LV_USE_PERF_MONITOR=0
+ -D LV_USE_MEM_MONITOR=0
+ -D LV_USE_LOG=0
+ -D LV_BUILD_TEST=0
+ -D USE_LOG_DEBUG
+ -D LOG_DEBUG_INC=\"DebugConfiguration.h\"
+ -D RAM_SIZE=5120
+ -D LGFX_BUFSIZE=153600
+ -D LGFX_DRIVER_TEMPLATE
+ -D DISPLAY_SIZE=320x240
+ -D LGFX_DRIVER=LGFX_GENERIC
+ -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_GENERIC.h\"
+ -D VIEW_320x240
+ -D LGFX_PANEL=ST7789
+ -D LGFX_ROTATION=3
+ -D LGFX_CFG_HOST=SPI3_HOST
+ -D LGFX_PIN_SCK=40
+ -D LGFX_PIN_MOSI=47
+ -D LGFX_PIN_DC=15
+ -D LGFX_PIN_CS=16
+ -D LGFX_PIN_BL=17
+ -D LGFX_PIN_RST=14
+ -D LGFX_SCREEN_WIDTH=240
+ -D LGFX_SCREEN_HEIGHT=320
+ -D LGFX_INVERT_LIGHT=true
+ -D SPI_FREQUENCY=75000000
+; -D MAP_FULL_REDRAW
+ -D MUI_WIFI_PS_MIN_MODEM
+ -D DEFAULT_FTP_SERVER_NETWORK_TYPE_ESP32=NETWORK_ESP32
+ -D DEFAULT_STORAGE_TYPE_ESP32=STORAGE_SD
+ -D CHARGING_VOLTAGE=4.25
+
+lib_deps =
+ ${thinknode_m9_base.lib_deps}
+ https://github.com/meshtastic/device-ui/archive/e8a5ff337d1ead20b290307fb2159ad27fe47f86.zip ; PR314 input-policy
+ https://github.com/mverch67/MultiFTPServer/archive/0e854335b9916ed9f2d3bcfe68975ce746992ccd.zip
+
+custom_sdkconfig =
+ ${esp32s3_base.custom_sdkconfig}
+ ${device-ui_base.custom_sdkconfig}
\ No newline at end of file
diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h b/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h
new file mode 100644
index 0000000000..e5fe182c4f
--- /dev/null
+++ b/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h
@@ -0,0 +1,11 @@
+#include "RadioLib.h"
+
+static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
+
+static const Module::RfSwitchMode_t rfswitch_table[] = {
+ // mode DIO5 DIO6
+ {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}},
+ {LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}},
+ {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}},
+ {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE,
+};
diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp
new file mode 100644
index 0000000000..279dedeb50
--- /dev/null
+++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp
@@ -0,0 +1,34 @@
+#include "variant.h"
+#include "Arduino.h"
+#include "SPILock.h"
+#include "Wire.h"
+
+void earlyInitVariant()
+{
+ pinMode(LORA_CS, OUTPUT);
+ digitalWrite(LORA_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(TFT_CS, OUTPUT);
+ digitalWrite(TFT_CS, HIGH);
+ delay(100);
+}
+
+void lateInitVariant()
+{
+ // configure keyboard long-press time
+ const uint16_t ms = 700;
+ concurrency::LockGuard g(spiLock);
+ Wire.beginTransmission(0x6C);
+ Wire.write(0x03);
+ Wire.write((ms >> 8) & 0xFF);
+ Wire.write(ms & 0xFF);
+ Wire.endTransmission();
+}
+
+void variant_shutdown()
+{
+ uint64_t gpioMask = (1ULL << KB_INT);
+ gpio_pulldown_en((gpio_num_t)KB_INT);
+ esp_sleep_enable_ext1_wakeup(gpioMask, ESP_EXT1_WAKEUP_ANY_HIGH);
+}
\ No newline at end of file
diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h
new file mode 100644
index 0000000000..543a690899
--- /dev/null
+++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h
@@ -0,0 +1,107 @@
+#define CANNED_MESSAGE_MODULE_ENABLE 1
+#define PRESET_MESSAGE_MODULE_ENABLE 1
+
+/*Power*/
+#define VEXT_ENABLE 18
+#define VEXT_ON_VALUE LOW
+#define PIN_GPS_EN 11
+#define GPS_EN_ACTIVE LOW
+
+#define USE_POWERSAVE
+#define SLEEP_TIME 120
+
+/*Wire Interface*/
+#define WIRE_INTERFACES_COUNT 2
+// I2C keyboard
+#define I2C_SCL 21
+#define I2C_SDA 20
+#define KB_INT 12 // STC8H key-press interrupt (idle low, rising edge on press)
+#define KB_INT_WAKE_ON_HIGH 1 // KB_INT rests low; wake light sleep on its HIGH (active) level
+#define KB_LED 46 // STC8H keypad backlight LED
+// I2C peripheral
+#define I2C_SCL1 6
+#define I2C_SDA1 7
+
+/*BUZZER*/
+#define PIN_BUZZER 9
+
+/*CHARGE_CHECK*/
+#define EXT_PWR_DETECT 1
+// #define EXT_CHRG_DETECT 1
+#define EXT_PWR_DETECT_VALUE LOW
+
+/*GPS*/
+#define HAS_GPS 1
+#define GPS_BAUDRATE 115200
+#define PIN_GPS_RESET 5
+#define PIN_GPS_PPS 4
+#define GPS_TX_PIN 3
+#define GPS_RX_PIN 2
+#define GPS_THREAD_INTERVAL 50
+
+/*SPI*/
+#define SPI_MOSI 47
+#define SPI_SCK 40
+#define SPI_MISO 38
+
+/*Screen*/
+#define ST7789_CS 16
+#define ST7789_RS 15
+#define ST7789_TE 19
+#define ST7789_SDA SPI_MOSI // MOSI
+#define ST7789_SCK SPI_SCK
+#define ST7789_RESET 14
+#define ST7789_MISO SPI_MISO
+#define ST7789_BUSY -1
+#define ST7789_BL 17
+#define ST7789_SPI_HOST SPI3_HOST
+#define SPI_READ_FREQUENCY 16000000
+
+#define USE_TFTDISPLAY 1
+#define HAS_SPI_TFT 1
+#define TFT_CS ST7789_CS
+#define TFT_BL ST7789_BL
+#define TFT_HEIGHT 320
+#define TFT_WIDTH 240
+#define TFT_OFFSET_X 0
+#define TFT_OFFSET_Y 0
+#define TFT_OFFSET_ROTATION 0
+#define TFT_PWM_FREQ 44000
+#define TFT_PWM_CHANNEL 7
+#define TFT_INVERT_LIGHT true
+#define TFT_BACKLIGHT_ON LOW
+#define SCREEN_ROTATE
+#define SCREEN_TRANSITION_FRAMERATE 10
+#define BRIGHTNESS_DEFAULT 128
+
+/*Lora radio*/
+#define HW_SPI1_DEVICE
+#define LORA_SCK SPI_SCK
+#define LORA_MISO SPI_MISO
+#define LORA_MOSI SPI_MOSI
+#define LORA_CS 39
+#define LORA_RESET 45
+#define LORA_DIO0 41
+#define LORA_DIO1 42
+
+#define USE_LR1110
+#define LR1110_IRQ_PIN LORA_DIO1
+#define LR1110_NRESET_PIN LORA_RESET
+#define LR1110_BUSY_PIN LORA_DIO0
+#define LR1110_SPI_NSS_PIN LORA_CS
+#define LR1110_SPI_SCK_PIN LORA_SCK
+#define LR1110_SPI_MOSI_PIN LORA_MOSI
+#define LR1110_SPI_MISO_PIN LORA_MISO
+#define LR11X0_DIO3_TCXO_VOLTAGE 3.3
+#define LR11X0_DIO_AS_RF_SWITCH
+
+/*RTC*/
+#define PCF8563_RTC 0x51
+
+/*BATTERY*/
+#define BATTERY_PIN 13
+#define BATTERY_IMMUTABLE
+#define ADC_MULTIPLIER 2.0f
+#define BAT_MEASURE_ADC_UNIT ADC_UNIT_2
+#define ADC_CHANNEL ADC_CHANNEL_2
+#define OCV_ARRAY 4200, 4080, 3980, 3920, 3870, 3820, 3790, 3750, 3700, 3600, 3100
diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini
index 047371db9e..6448777935 100644
--- a/variants/esp32s3/t-deck/platformio.ini
+++ b/variants/esp32s3/t-deck/platformio.ini
@@ -39,6 +39,10 @@ lib_deps = ${esp32s3_base.lib_deps}
extends = env:t-deck
board_level = pr
+extra_scripts =
+ ${env:t-deck.extra_scripts}
+ extra_scripts/ld_response_file.py
+
build_flags =
${env:t-deck.build_flags}
-D CONFIG_DISABLE_HAL_LOCKS=1 ; "feels" to be a bit more stable without locks