From 3abe415cf7c2623b7b4eb6951d525d89111361f3 Mon Sep 17 00:00:00 2001 From: Hacuchino-hash <246103064+Hacuchino-hash@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:03:44 -0500 Subject: [PATCH 1/2] Add RGB LED and haptic notifications for SenseCAP MeshTracker X1 --- examples/companion_radio/NodePrefs.h | 2 + examples/companion_radio/ui-orig/UITask.cpp | 124 +++++++++++++++++++- examples/companion_radio/ui-orig/UITask.h | 7 ++ src/helpers/ui/DRV2605Vibration.cpp | 47 ++++++++ src/helpers/ui/DRV2605Vibration.h | 39 ++++++ variants/meshtracker_x1/platformio.ini | 11 ++ 6 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 src/helpers/ui/DRV2605Vibration.cpp create mode 100644 src/helpers/ui/DRV2605Vibration.h diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 39a5386a9f..4ec1803e1c 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -28,6 +28,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file uint32_t ble_pin = 0; uint8_t advert_loc_policy = 0; uint8_t buzzer_quiet = 0; + uint8_t vibe_quiet = 0; uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) uint32_t gps_interval = 0; // GPS read interval in seconds uint8_t autoadd_config = 0; // bitmask for auto-add contacts config @@ -101,6 +102,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file def("defs_key", (void *) _parent->default_scope_key, sizeof(_parent->default_scope_key)); def("pin", _parent->ble_pin); def("buzz_q", _parent->buzzer_quiet); + def("vibe_q", _parent->vibe_quiet); def("auto_add", _parent->autoadd_config); // bitmask for auto-add contacts config def("man_add", _parent->manual_add_contacts); def("tel_base", _parent->telemetry_mode_base); diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 09fc8e7705..08fc9b7ab3 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -6,12 +6,19 @@ #define AUTO_OFF_MILLIS 15000 // 15 seconds #define BOOT_SCREEN_MILLIS 3000 // 3 seconds -#ifdef PIN_STATUS_LED +#if defined(PIN_STATUS_LED) || defined(PIN_STATUS_LED_R) #define LED_ON_MILLIS 20 #define LED_ON_MSG_MILLIS 200 #define LED_CYCLE_MILLIS 4000 #endif +#if defined(PIN_STATUS_LED_R) && defined(PIN_STATUS_LED_G) && defined(PIN_STATUS_LED_B) +#define STATUS_LED_RGB 1 +#ifndef LOW_BATT_MILLIVOLTS +#define LOW_BATT_MILLIVOLTS 3500 +#endif +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif @@ -60,6 +67,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no buzzer.startup(); #endif +#ifdef HAS_DRV2605 + vibration.begin(); + vibration.quiet(_node_prefs->vibe_quiet); +#endif + // Initialize digital button if available #ifdef PIN_USER_BTN _userButton = new Button(PIN_USER_BTN, USER_BTN_PRESSED); @@ -130,6 +142,10 @@ void UITask::clearMsgPreview() { void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { _msgcount = msgcount; +#ifdef HAS_DRV2605 + vibration.trigger(); // vibrate even while the app is connected (honors quiet + cooldown) +#endif + if (path_len == 0xFF) { sprintf(_origin, "(F) %s", from_name); } else { @@ -260,8 +276,88 @@ void UITask::renderCurrScreen() { _need_refresh = false; } +#ifdef STATUS_LED_RGB +static void statusLedWrite(uint8_t r, uint8_t g, uint8_t b) { + analogWrite(PIN_STATUS_LED_R, r); + analogWrite(PIN_STATUS_LED_G, g); + analogWrite(PIN_STATUS_LED_B, b); +} + +static void statusLedWheel(uint8_t pos) { // smooth hue sweep, 0..255 + if (pos < 85) { + statusLedWrite(255 - pos * 3, pos * 3, 0); + } else if (pos < 170) { + pos -= 85; + statusLedWrite(0, 255 - pos * 3, pos * 3); + } else { + pos -= 170; + statusLedWrite(pos * 3, 0, 255 - pos * 3); + } +} +#endif + void UITask::userLedHandler() { -#ifdef PIN_STATUS_LED +#ifdef STATUS_LED_RGB + static bool booted = false; + static bool flourish_active = false; + static unsigned long flourish_until = 0; + static int prev_msgcount = 0; + static int state = 0; + static unsigned long next_change = 0; + static int last_increment = 0; + static unsigned long next_batt_check = 0; + static bool low_batt = false; + + unsigned long cur_time = millis(); + + if (!booted) { // color sweep on boot + booted = true; + flourish_until = cur_time + 1200; + flourish_active = true; + } + if (_msgcount > prev_msgcount) { // color sweep when a new message arrives + flourish_until = cur_time + 800; + flourish_active = true; + } + prev_msgcount = _msgcount; + + if (flourish_active) { + if (cur_time < flourish_until) { + statusLedWheel((cur_time % 1200) * 255 / 1200); + return; + } + statusLedWrite(0, 0, 0); + flourish_active = false; + state = 0; + next_change = cur_time; + } + + if (cur_time > next_batt_check) { // battery reads are not free, keep them rare + low_batt = _board->getBattMilliVolts() < LOW_BATT_MILLIVOLTS; + next_batt_check = cur_time + 60000; + } + + if (cur_time > next_change) { + if (state == 0) { + state = 1; + last_increment = (_msgcount > 0) ? LED_ON_MSG_MILLIS : LED_ON_MILLIS; + next_change = cur_time + last_increment; + if (low_batt) { + statusLedWrite(255, 0, 0); // red: battery low + } else if (_msgcount > 0) { + statusLedWrite(255, 90, 0); // amber: unread messages + } else if (_connected) { + statusLedWrite(0, 0, 255); // blue: app connected + } else { + statusLedWrite(0, 255, 0); // green: heartbeat + } + } else { + state = 0; + next_change = cur_time + LED_CYCLE_MILLIS - last_increment; + statusLedWrite(0, 0, 0); + } + } +#elif defined(PIN_STATUS_LED) static int state = 0; static int next_change = 0; static int last_increment = 0; @@ -406,8 +502,26 @@ void UITask::handleButtonDoublePress() { void UITask::handleButtonTriplePress() { MESH_DEBUG_PRINTLN("UITask: triple press triggered"); - // Toggle buzzer quiet mode - #ifdef PIN_BUZZER +#if defined(PIN_BUZZER) && defined(HAS_DRV2605) + // cycle alert modes: Buzz+Vibe -> Buzz only -> Vibe only -> Silent + int mode = (_node_prefs->buzzer_quiet ? 2 : 0) | (_node_prefs->vibe_quiet ? 1 : 0); + mode = (mode + 1) & 3; + _node_prefs->buzzer_quiet = (mode & 2) ? 1 : 0; + _node_prefs->vibe_quiet = (mode & 1) ? 1 : 0; + buzzer.quiet(_node_prefs->buzzer_quiet); + vibration.quiet(_node_prefs->vibe_quiet); + // audible/tactile confirmation of the new mode (no screen on some boards) + if (!_node_prefs->buzzer_quiet) notify(UIEventType::ack); + if (!_node_prefs->vibe_quiet) vibration.trigger(true); + switch (mode) { + case 0: sprintf(_alert, "Alerts: Buzz+Vibe"); break; + case 1: sprintf(_alert, "Alerts: Buzz only"); break; + case 2: sprintf(_alert, "Alerts: Vibe only"); break; + default: sprintf(_alert, "Alerts: Silent"); break; + } + the_mesh.savePrefs(); + _need_refresh = true; +#elif defined(PIN_BUZZER) if (buzzer.isQuiet()) { buzzer.quiet(false); notify(UIEventType::ack); @@ -419,7 +533,7 @@ void UITask::handleButtonTriplePress() { _node_prefs->buzzer_quiet = buzzer.isQuiet(); the_mesh.savePrefs(); _need_refresh = true; - #endif +#endif } void UITask::handleButtonQuadruplePress() { diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 961c07a031..02d126e8fe 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -14,11 +14,18 @@ #include "Button.h" +#ifdef HAS_DRV2605 + #include +#endif + class UITask : public AbstractUITask { DisplayDriver* _display; SensorManager* _sensors; #ifdef PIN_BUZZER genericBuzzer buzzer; +#endif +#ifdef HAS_DRV2605 + DRV2605Vibration vibration; #endif unsigned long _next_refresh, _auto_off; NodePrefs* _node_prefs; diff --git a/src/helpers/ui/DRV2605Vibration.cpp b/src/helpers/ui/DRV2605Vibration.cpp new file mode 100644 index 0000000000..3a42bb8842 --- /dev/null +++ b/src/helpers/ui/DRV2605Vibration.cpp @@ -0,0 +1,47 @@ +#ifdef HAS_DRV2605 + +#include "DRV2605Vibration.h" +#include + +void DRV2605Vibration::begin() { +#ifdef PIN_DRV_EN + pinMode(PIN_DRV_EN, OUTPUT); + digitalWrite(PIN_DRV_EN, HIGH); // power up the haptic driver + delay(10); +#endif + if (!drv.begin(&Wire)) { + return; // no haptic driver found, stay silent + } +#ifdef DRV2605_USE_LRA + // LRA mode: 4x brake factor, medium loop gain, back-EMF gain 2 + drv.writeRegister8(DRV2605_REG_FEEDBACK, 0xB6); +#endif + drv.selectLibrary(1); + drv.setMode(DRV2605_MODE_INTTRIG); + _ready = true; +} + +void DRV2605Vibration::trigger(bool force) { + if (!_ready || _quiet) return; + unsigned long now = millis(); + if (!force && _last_trigger != 0 && now - _last_trigger < VIBRATION_TIMEOUT) return; + _last_trigger = now; + drv.setWaveform(0, DRV2605_EFFECT); + drv.setWaveform(1, 0); // pause + drv.setWaveform(2, DRV2605_EFFECT); + drv.setWaveform(3, 0); // end of sequence + drv.go(); +} + +void DRV2605Vibration::loop() { +} + +bool DRV2605Vibration::isVibrating() { + return false; // effects are short, treat as instantaneous +} + +void DRV2605Vibration::stop() { + if (_ready) drv.stop(); +} + +#endif // ifdef HAS_DRV2605 diff --git a/src/helpers/ui/DRV2605Vibration.h b/src/helpers/ui/DRV2605Vibration.h new file mode 100644 index 0000000000..a40894be11 --- /dev/null +++ b/src/helpers/ui/DRV2605Vibration.h @@ -0,0 +1,39 @@ +#pragma once + +#ifdef HAS_DRV2605 + +#include +#include + +/* + * Vibration control class for boards where the motor is behind a + * DRV2605 haptic driver on I2C (e.g. Seeed SenseCAP MeshTracker X1). + * Same interface as GenericVibration. + */ + +#ifndef VIBRATION_TIMEOUT +#define VIBRATION_TIMEOUT 5000 // cooldown between vibrations +#endif + +#ifndef DRV2605_EFFECT +#define DRV2605_EFFECT 16 // "1000 ms alert" from the DRV2605 effect library +#endif + +class DRV2605Vibration { +public: + void begin(); // power up and init the DRV2605 + void trigger(bool force = false); // trigger vibration; force skips the cooldown + void loop(); // no-op, the DRV2605 plays effects autonomously + bool isVibrating(); + void stop(); // stop vibration immediately + void quiet(bool q) { _quiet = q; } + bool isQuiet() const { return _quiet; } + +private: + Adafruit_DRV2605 drv; + bool _ready = false; + bool _quiet = false; + unsigned long _last_trigger = 0; +}; + +#endif // ifdef HAS_DRV2605 diff --git a/variants/meshtracker_x1/platformio.ini b/variants/meshtracker_x1/platformio.ini index dbab5ca633..185069168f 100644 --- a/variants/meshtracker_x1/platformio.ini +++ b/variants/meshtracker_x1/platformio.ini @@ -12,6 +12,9 @@ build_flags = ${nrf52_base.build_flags} -D PIN_USER_BTN=6 -D USER_BTN_PRESSED=HIGH -D PIN_STATUS_LED=24 + -D PIN_STATUS_LED_R=3 ; P0.3 red + -D PIN_STATUS_LED_G=24 ; P0.24 green + -D PIN_STATUS_LED_B=28 ; P0.28 blue -D RADIO_CLASS=CustomLR2021 -D USE_LR2021 -D WRAPPER_CLASS=CustomLR2021Wrapper @@ -77,16 +80,20 @@ build_flags = ${MeshTracker_X1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=25 + -D HAS_DRV2605=1 + -D DRV2605_USE_LRA=1 -D ENABLE_USB_INTERFACE build_src_filter = ${MeshTracker_X1.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${MeshTracker_X1.lib_deps} densaugeo/base64 @ ~1.4.0 stevemarple/MicroNMEA @ ^2.0.6 end2endzone/NonBlockingRTTTL@^1.3.0 + adafruit/Adafruit DRV2605 Library @ ^1.2.4 [env:MeshTracker_X1_companion_radio_ble] extends = MeshTracker_X1 @@ -104,14 +111,18 @@ build_flags = ${MeshTracker_X1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=25 + -D HAS_DRV2605=1 + -D DRV2605_USE_LRA=1 -D ADVERT_NAME='"@@MAC"' build_src_filter = ${MeshTracker_X1.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${MeshTracker_X1.lib_deps} densaugeo/base64 @ ~1.4.0 stevemarple/MicroNMEA @ ^2.0.6 end2endzone/NonBlockingRTTTL@^1.3.0 + adafruit/Adafruit DRV2605 Library @ ^1.2.4 From 335ebf546d6f1f15c0bf4dbce08dd8a65d1ceb2c Mon Sep 17 00:00:00 2001 From: Hacuchino-hash <246103064+Hacuchino-hash@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:34:02 -0500 Subject: [PATCH 2/2] Add SPA06 barometer telemetry and charge status LED for MeshTracker X1 --- examples/companion_radio/ui-orig/UITask.cpp | 30 +++++++++++++++++++++ variants/meshtracker_x1/platformio.ini | 2 ++ variants/meshtracker_x1/target.cpp | 16 +++++++++++ variants/meshtracker_x1/target.h | 3 +++ variants/meshtracker_x1/variant.cpp | 3 ++- variants/meshtracker_x1/variant.h | 1 + 6 files changed, 54 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 08fc9b7ab3..1e9613165b 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -337,6 +337,36 @@ void UITask::userLedHandler() { next_batt_check = cur_time + 60000; } +#ifdef EXT_CHRG_DETECT + // charge status display while docked (skipped when messages are waiting, + // so the unread indication is never masked) + static unsigned long next_pwr_check = 0; + static bool ext_powered = false, ext_charging = false; + if (cur_time > next_pwr_check) { + ext_powered = _board->isExternalPowered(); + bool chrg = digitalRead(EXT_CHRG_DETECT) == LOW; + #ifdef EXT_CHRG_DONE + if (digitalRead(EXT_CHRG_DONE) == LOW) chrg = false; // charge-done wins + #endif + ext_charging = ext_powered && chrg; + next_pwr_check = cur_time + 1000; + } + if (ext_powered && _msgcount == 0) { + if (ext_charging) { + // amber breathing while charging + int ph = cur_time % 2000; + int v = ph < 1000 ? ph : 2000 - ph; + uint8_t lvl = (uint8_t)(v * 255 / 1000); + statusLedWrite(lvl, (uint8_t)(lvl * 35 / 100), 0); + } else { + statusLedWrite(0, 40, 0); // dim solid green: charge complete + } + state = 0; + next_change = cur_time; + return; + } +#endif + if (cur_time > next_change) { if (state == 0) { state = 1; diff --git a/variants/meshtracker_x1/platformio.ini b/variants/meshtracker_x1/platformio.ini index 185069168f..6a46a79f96 100644 --- a/variants/meshtracker_x1/platformio.ini +++ b/variants/meshtracker_x1/platformio.ini @@ -32,6 +32,8 @@ build_flags = ${nrf52_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/meshtracker_x1> +lib_deps = ${nrf52_base.lib_deps} + https://github.com/adafruit/Adafruit_SPA06_003/archive/refs/tags/1.0.2.zip debug_tool = jlink upload_protocol = nrfutil diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp index 1139b8b713..d6d548b511 100644 --- a/variants/meshtracker_x1/target.cpp +++ b/variants/meshtracker_x1/target.cpp @@ -67,6 +67,17 @@ void MeshTrackerX1SensorManager::stop_gps() { bool MeshTrackerX1SensorManager::begin() { // init GPS Serial1.begin(GPS_BAUD_RATE); + + // init SPA06-003 barometer + baro_ok = spa06.begin(SPA06_003_DEFAULT_ADDR, &Wire) || spa06.begin(0x76, &Wire); + if (baro_ok) { + spa06.setPressureOversampling(SPA06_003_OVERSAMPLE_8); + spa06.setTemperatureOversampling(SPA06_003_OVERSAMPLE_8); + // 1 Hz continuous keeps reads non-blocking at minimal power cost + spa06.setPressureMeasureRate(SPA06_003_RATE_1); + spa06.setTemperatureMeasureRate(SPA06_003_RATE_1); + spa06.setMeasurementMode(SPA06_003_MEAS_CONTINUOUS_BOTH); + } return true; } @@ -74,12 +85,17 @@ bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, Cay if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission? telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); } + if (requester_permissions & TELEM_PERM_ENVIRONMENT && baro_ok) { + telemetry.addTemperature(TELEM_CHANNEL_SELF, spa06.readTemperature()); + telemetry.addBarometricPressure(TELEM_CHANNEL_SELF, spa06.readPressure()); + } return true; } void MeshTrackerX1SensorManager::loop() { static long next_gps_update = 0; + _nmea->loop(); if (millis() > next_gps_update) { diff --git a/variants/meshtracker_x1/target.h b/variants/meshtracker_x1/target.h index 665f432812..9741efb6af 100644 --- a/variants/meshtracker_x1/target.h +++ b/variants/meshtracker_x1/target.h @@ -8,12 +8,15 @@ #include #include #include +#include #ifdef DISPLAY_CLASS #include "NullDisplayDriver.h" #endif class MeshTrackerX1SensorManager: public SensorManager { bool gps_active = false; + bool baro_ok = false; + Adafruit_SPA06_003 spa06; LocationProvider * _nmea; void start_gps(); diff --git a/variants/meshtracker_x1/variant.cpp b/variants/meshtracker_x1/variant.cpp index a0e6438012..5a685a8efb 100644 --- a/variants/meshtracker_x1/variant.cpp +++ b/variants/meshtracker_x1/variant.cpp @@ -62,7 +62,8 @@ const uint32_t g_ADigitalPinMap[PINS_COUNT + 1] = void initVariant() { pinMode(BATTERY_PIN, INPUT); - pinMode(EXT_CHRG_DETECT, INPUT); + pinMode(EXT_CHRG_DETECT, INPUT_PULLUP); + pinMode(EXT_CHRG_DONE, INPUT_PULLUP); pinMode(EXT_PWR_DETECT, INPUT); pinMode(PIN_BUTTON1, INPUT_PULLDOWN); diff --git a/variants/meshtracker_x1/variant.h b/variants/meshtracker_x1/variant.h index 8e9bae8fa1..c3dbd274f3 100644 --- a/variants/meshtracker_x1/variant.h +++ b/variants/meshtracker_x1/variant.h @@ -25,6 +25,7 @@ #define ADC_MULTIPLIER (2.0F) #define EXT_CHRG_DETECT (35) // P1.3, LOW while charging +#define EXT_CHRG_DONE (36) // P1.4, LOW when charge complete #define EXT_PWR_DETECT (5) // P0.5 #define ADC_RESOLUTION (14)