From 68144ade4146fddc3a77cff852169b100b7f1501 Mon Sep 17 00:00:00 2001 From: Andy Hoobing Date: Sun, 23 Aug 2026 18:21:42 -0600 Subject: [PATCH 1/3] ThinkNode M6: make GPS and both LEDs work in the repeater build The M6 is a sealed solar repeater with a soldered-on L76K GNSS and two LEDs, but neither was usable in the ThinkNode_M6_repeater build. GPS: the repeater's NodePrefs default gps_enabled=0, so sensors.begin() detected the L76K and then immediately powered it back down via _location->stop(). With no button or screen on a sealed enclosure there was no way to turn it on short of a USB console. Even once enabled, the repeater defaulted advert_loc_policy to ADVERT_LOC_PREFS, so adverts carried the fixed ADVERT_LAT/ADVERT_LON (0,0) and ignored the GNSS entirely. Both defaults are now build-time overridable (GPS_ENABLED_DEFAULT and ADVERT_LOC_POLICY_DEFAULT) and unchanged for every other board; the M6 repeater env opts into GPS-on and ADVERT_LOC_SHARE. It also sets ENV_SKIP_GPS_DETECT, because the 1s "is a GPS attached?" probe gates the 'gps' setting, and losing that race left GPS unreachable until reboot -- the M6 always has the module fitted. Everything stays runtime-adjustable via 'gps on'/'gps off' and 'gps advert prefs|share|none'. LEDs: only the blue LED was driven (LoRa TX activity). The red LED -- the enclosure's power LED -- was configured as an output and then never touched. It now indicates state: solid through boot, then a ~1% duty heartbeat, one blink per 5s while running and two once the GNSS has a fix, so a deployed node can be checked through the case without a laptop. onBootComplete() and powerOff() handle it too. The heartbeat is driven from a small EnvironmentSensorManager subclass, the same pattern thinknode_m1 uses, since its loop() already runs every iteration and holds the live GNSS state -- no core or shared example changes needed. Verified: ThinkNode_M6_repeater builds (flash 47.8%, RAM 13.4%) and RAK_4631_repeater still builds unchanged, confirming the shared simple_repeater defaults are untouched for other boards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7b3E6KcBEGZ2zr2jZMrBL --- examples/simple_repeater/MyMesh.cpp | 14 ++++++-- variants/thinknode_m6/ThinkNodeM6Board.cpp | 38 ++++++++++++++++++++++ variants/thinknode_m6/ThinkNodeM6Board.h | 23 +++++++++++++ variants/thinknode_m6/platformio.ini | 10 ++++++ variants/thinknode_m6/target.cpp | 14 ++++++-- variants/thinknode_m6/target.h | 20 +++++++++++- 6 files changed, 114 insertions(+), 5 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a711ec0a51..ef86df5952 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -29,6 +29,16 @@ #define ADVERT_LON 0.0 #endif +// GPS defaults for a NEW install. Boards with a permanently-fitted GNSS (and no +// UI to switch it on) override these in their platformio.ini; every other board +// keeps the historical "GPS off, advertise the configured coordinates" defaults. +#ifndef GPS_ENABLED_DEFAULT + #define GPS_ENABLED_DEFAULT 0 +#endif +#ifndef ADVERT_LOC_POLICY_DEFAULT + #define ADVERT_LOC_POLICY_DEFAULT ADVERT_LOC_PREFS +#endif + #ifndef ADMIN_PASSWORD #define ADMIN_PASSWORD "password" #endif @@ -919,9 +929,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret)); // GPS defaults - _prefs.gps_enabled = 0; + _prefs.gps_enabled = GPS_ENABLED_DEFAULT; _prefs.gps_interval = 0; - _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + _prefs.advert_loc_policy = ADVERT_LOC_POLICY_DEFAULT; _prefs.adc_multiplier = 0.0f; // 0.0f means use default board multiplier diff --git a/variants/thinknode_m6/ThinkNodeM6Board.cpp b/variants/thinknode_m6/ThinkNodeM6Board.cpp index 8ebae64c64..d42436f49e 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.cpp +++ b/variants/thinknode_m6/ThinkNodeM6Board.cpp @@ -15,9 +15,47 @@ void ThinkNodeM6Board::begin() { digitalWrite(P_LORA_TX_LED, LOW); #endif + // Red LED solid while booting; onBootComplete() hands it over to the heartbeat. + pinMode(PIN_LED_RED, OUTPUT); + digitalWrite(PIN_LED_RED, LED_STATE_ON); + _booting = true; + delay(10); // give sx1262 some time to power up } +void ThinkNodeM6Board::onBootComplete() { + _booting = false; + _status_cycle_start = millis(); + digitalWrite(PIN_LED_RED, !LED_STATE_ON); +} + +void ThinkNodeM6Board::updateStatusLed(bool gps_fix) { + if (_booting) return; // still solid-on, boot hasn't finished + + unsigned long phase = millis() - _status_cycle_start; // wrap-safe + if (phase >= STATUS_LED_PERIOD_MS) { + _status_cycle_start += STATUS_LED_PERIOD_MS; + phase -= STATUS_LED_PERIOD_MS; + // Latch the pattern once per cycle so a fix flapping mid-blink can't + // produce a half-formed pulse. + _status_blinks = gps_fix ? 2 : 1; + if (phase >= STATUS_LED_PERIOD_MS) { // fell far behind (long sleep); resync + _status_cycle_start = millis(); + phase = 0; + } + } + + bool on = false; + for (uint8_t i = 0; i < _status_blinks; i++) { + unsigned long start = i * (unsigned long)(STATUS_LED_ON_MS + STATUS_LED_GAP_MS); + if (phase >= start && phase < start + STATUS_LED_ON_MS) { + on = true; + break; + } + } + digitalWrite(PIN_LED_RED, on ? LED_STATE_ON : !LED_STATE_ON); +} + uint16_t ThinkNodeM6Board::getBattMilliVolts() { int adcvalue = 0; diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 78815e2c9c..4a168ab24d 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -12,7 +12,19 @@ #define PIN_VBAT_READ BATTERY_PIN #define REAL_VBAT_MV_PER_LSB (VBAT_DIVIDER_COMP * VBAT_MV_PER_LSB) +// Status LED (PIN_LED_RED, the enclosure's power LED) heartbeat timings. +// The M6 is a sealed outdoor box: a slow blink is the only way to tell a live +// node from a dead one, and to see whether the GNSS has a fix, without opening +// it up or attaching a laptop. Duty cycle is ~1% so it costs nothing on solar. +#define STATUS_LED_PERIOD_MS 5000 // one heartbeat every 5s +#define STATUS_LED_ON_MS 40 // length of each blink +#define STATUS_LED_GAP_MS 160 // dark gap between blinks of a double-blink + class ThinkNodeM6Board : public NRF52BoardDCDC { + bool _booting = true; + unsigned long _status_cycle_start = 0; + uint8_t _status_blinks = 1; + protected: #if NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; @@ -23,6 +35,16 @@ class ThinkNodeM6Board : public NRF52BoardDCDC { void begin(); uint16_t getBattMilliVolts() override; + // Boot indicator: red LED stays solid from begin() until the sketch reports + // that setup() finished, so a boot loop is visible as a flickering LED. + void onBootComplete() override; + + // Drives the red LED heartbeat. Called every iteration from the variant's + // sensor manager, which is the one place that knows the live GNSS state: + // 1 blink / 5s -> running, no GPS fix (yet) + // 2 blinks / 5s -> running, GPS fix acquired + void updateStatusLed(bool gps_fix); + #if defined(P_LORA_TX_LED) void onBeforeTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on @@ -42,6 +64,7 @@ class ThinkNodeM6Board : public NRF52BoardDCDC { #ifdef P_LORA_TX_LED digitalWrite(P_LORA_TX_LED, LOW); #endif + digitalWrite(PIN_LED_RED, LOW); // power off board NRF52Board::powerOff(); diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index ad7e6902cf..c0efc35221 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -47,6 +47,16 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 +; The L76K GNSS is soldered to the M6 and the enclosure is sealed, so there is +; no button or screen to switch GPS on with. Skip the 1s "is a GPS attached?" +; probe (it gates the 'gps' setting, and losing that race leaves GPS +; unreachable until reboot), enable GPS for new installs, and advertise the +; position the GNSS actually reports instead of the fixed 0,0 above. +; All of it stays changeable at runtime over the serial CLI: 'gps on'/'gps off', +; 'gps advert prefs|share|none', and bare 'gps' to print fix + satellite count. + -D ENV_SKIP_GPS_DETECT=1 + -D GPS_ENABLED_DEFAULT=1 + -D ADVERT_LOC_POLICY_DEFAULT=ADVERT_LOC_SHARE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 ; -D GPS_NMEA_DEBUG=1 diff --git a/variants/thinknode_m6/target.cpp b/variants/thinknode_m6/target.cpp index de167194e4..e10873294b 100644 --- a/variants/thinknode_m6/target.cpp +++ b/variants/thinknode_m6/target.cpp @@ -13,9 +13,19 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); #ifdef ENV_INCLUDE_GPS MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); -EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +ThinkNodeM6SensorManager sensors = ThinkNodeM6SensorManager(nmea); + +void ThinkNodeM6SensorManager::loop() { + EnvironmentSensorManager::loop(); + board.updateStatusLed(gps_active && _location != NULL && _location->isValid()); +} #else -EnvironmentSensorManager sensors = EnvironmentSensorManager(); +ThinkNodeM6SensorManager sensors = ThinkNodeM6SensorManager(); + +void ThinkNodeM6SensorManager::loop() { + EnvironmentSensorManager::loop(); + board.updateStatusLed(false); +} #endif #ifdef DISPLAY_CLASS diff --git a/variants/thinknode_m6/target.h b/variants/thinknode_m6/target.h index 76188e584e..e25c6be5be 100644 --- a/variants/thinknode_m6/target.h +++ b/variants/thinknode_m6/target.h @@ -14,10 +14,28 @@ #include #endif +// Wraps the stock environment sensor manager purely to drive the status LED. +// Its loop() is already called every iteration by every example sketch, and it +// is the only place holding the live GNSS state, so it is where the board gets +// told whether to blink once (no fix) or twice (fix). +#ifdef ENV_INCLUDE_GPS +class ThinkNodeM6SensorManager : public EnvironmentSensorManager { +public: + ThinkNodeM6SensorManager(LocationProvider& location) : EnvironmentSensorManager(location) { } + void loop() override; +}; +#else +class ThinkNodeM6SensorManager : public EnvironmentSensorManager { +public: + ThinkNodeM6SensorManager() : EnvironmentSensorManager() { } + void loop() override; +}; +#endif + extern ThinkNodeM6Board board; extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; -extern EnvironmentSensorManager sensors; +extern ThinkNodeM6SensorManager sensors; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; From 4ffe0ab2a8ab8fd7163d30c166d08e800642d7c3 Mon Sep 17 00:00:00 2001 From: Andy Hoobing Date: Sun, 23 Aug 2026 18:26:35 -0600 Subject: [PATCH 2/3] ThinkNode M6: make the status LED actually catchable A 40ms blink every 5s is easy to miss entirely at arm's length. The LEDs sit on the bottom face of the enclosure next to the USB-C port, read in daylight, so stretch each blink to 150ms (still ~3% duty) and add a three-flash boot signature on both LEDs at the end of setup() -- that is how you confirm a freshly flashed node came up, without a console. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7b3E6KcBEGZ2zr2jZMrBL --- variants/thinknode_m6/ThinkNodeM6Board.cpp | 15 +++++++++++++++ variants/thinknode_m6/ThinkNodeM6Board.h | 13 +++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/variants/thinknode_m6/ThinkNodeM6Board.cpp b/variants/thinknode_m6/ThinkNodeM6Board.cpp index d42436f49e..88481eac2a 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.cpp +++ b/variants/thinknode_m6/ThinkNodeM6Board.cpp @@ -24,6 +24,21 @@ void ThinkNodeM6Board::begin() { } void ThinkNodeM6Board::onBootComplete() { + // Flash both LEDs together a few times. This is the one moment worth being + // loud about: it is how you confirm a freshly flashed node actually came up. + for (uint8_t i = 0; i < BOOT_FLASH_COUNT; i++) { + digitalWrite(PIN_LED_RED, LED_STATE_ON); +#ifdef P_LORA_TX_LED + digitalWrite(P_LORA_TX_LED, HIGH); +#endif + delay(BOOT_FLASH_ON_MS); + digitalWrite(PIN_LED_RED, !LED_STATE_ON); +#ifdef P_LORA_TX_LED + digitalWrite(P_LORA_TX_LED, LOW); +#endif + delay(BOOT_FLASH_OFF_MS); + } + _booting = false; _status_cycle_start = millis(); digitalWrite(PIN_LED_RED, !LED_STATE_ON); diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 4a168ab24d..d149c35129 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -16,9 +16,18 @@ // The M6 is a sealed outdoor box: a slow blink is the only way to tell a live // node from a dead one, and to see whether the GNSS has a fix, without opening // it up or attaching a laptop. Duty cycle is ~1% so it costs nothing on solar. +// The LEDs sit on the bottom face of the enclosure next to the USB-C port, so +// they are read at arm's length in daylight -- a blink has to be long enough to +// actually catch the eye. 150ms is comfortably visible and still only ~3% duty. #define STATUS_LED_PERIOD_MS 5000 // one heartbeat every 5s -#define STATUS_LED_ON_MS 40 // length of each blink -#define STATUS_LED_GAP_MS 160 // dark gap between blinks of a double-blink +#define STATUS_LED_ON_MS 150 // length of each blink +#define STATUS_LED_GAP_MS 200 // dark gap between blinks of a double-blink + +// Unmistakable "firmware is alive" signature at the end of setup(), so a fresh +// flash can be confirmed without a serial console. +#define BOOT_FLASH_COUNT 3 +#define BOOT_FLASH_ON_MS 120 +#define BOOT_FLASH_OFF_MS 120 class ThinkNodeM6Board : public NRF52BoardDCDC { bool _booting = true; From 6e9b6f94cd8b501e28c5441b24348c86de21a05b Mon Sep 17 00:00:00 2001 From: Andy Hoobing Date: Sun, 23 Aug 2026 18:45:10 -0600 Subject: [PATCH 3/3] ThinkNode M6: apply the same GPS fix to the room server build simple_room_server has the identical defect the repeater had: it defaults gps_enabled=0 and advert_loc_policy=ADVERT_LOC_PREFS, so on a sealed M6 the L76K is detected at boot and then powered straight back down, and adverts carry the fixed 0,0 instead of the GNSS position. Same approach as the repeater: both defaults become build-time overridable (declared in MyMesh.h alongside this example's other ADVERT_* fallbacks, matching its own convention) and are unchanged for every other board. The M6 room_server env opts in, and also skips the 1s GPS probe since the module is always fitted. The LED work needed nothing here -- the heartbeat lives in the variant's sensor manager, and simple_room_server already calls board.onBootComplete() and sensors.loop(). Verified: ThinkNode_M6_room_server and RAK_4631_room_server both build, and envdump confirms the new flags resolve for the M6 while remaining absent for RAK. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7b3E6KcBEGZ2zr2jZMrBL --- examples/simple_room_server/MyMesh.cpp | 4 ++-- examples/simple_room_server/MyMesh.h | 10 ++++++++++ variants/thinknode_m6/platformio.ini | 7 +++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 546d094fc8..fb66008f08 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -670,9 +670,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif // GPS defaults - _prefs.gps_enabled = 0; + _prefs.gps_enabled = GPS_ENABLED_DEFAULT; _prefs.gps_interval = 0; - _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + _prefs.advert_loc_policy = ADVERT_LOC_POLICY_DEFAULT; #if defined(USE_SX1262) || defined(USE_SX1268) #ifdef SX126X_RX_BOOSTED_GAIN diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5cf949c6bd..d376ee958e 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -61,6 +61,16 @@ #define ADVERT_LON 0.0 #endif +// GPS defaults for a NEW install. Boards with a permanently-fitted GNSS (and no +// UI to switch it on) override these in their platformio.ini; every other board +// keeps the historical "GPS off, advertise the configured coordinates" defaults. +#ifndef GPS_ENABLED_DEFAULT + #define GPS_ENABLED_DEFAULT 0 +#endif +#ifndef ADVERT_LOC_POLICY_DEFAULT + #define ADVERT_LOC_POLICY_DEFAULT ADVERT_LOC_PREFS +#endif + #ifndef ADMIN_PASSWORD #define ADMIN_PASSWORD "password" #endif diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index c0efc35221..cfac5e2cd5 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -74,6 +74,13 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' +; Same reasoning as the repeater env above: the L76K is soldered on and the +; enclosure is sealed, so GPS has to come up enabled and advertise the position +; the GNSS actually reports. Runtime CLI still wins ('gps on'/'gps off', +; 'gps advert prefs|share|none'). + -D ENV_SKIP_GPS_DETECT=1 + -D GPS_ENABLED_DEFAULT=1 + -D ADVERT_LOC_POLICY_DEFAULT=ADVERT_LOC_SHARE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ThinkNode_M6.build_src_filter}